bazis 0.106.0 → 0.108.0
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/cli/build.ts +16 -4
- package/cli/main.ts +3 -1
- package/cli/parseCli.ts +13 -6
- package/core/app/messages.ts +56 -1
- package/core/app/runApp.ts +15 -5
- package/core/http/Authorization/authorizeComposer.ts +4 -5
- package/core/http/Authorization/metadata.ts +17 -0
- package/core/http/Errors/messages.ts +21 -0
- package/core/http/HttpContext/HttpContext.ts +6 -0
- package/core/http/HttpServer.ts +39 -18
- package/core/http/Middleware/errorHandler.ts +11 -9
- package/core/http/OpenApi/openApiDocs.ts +3 -0
- package/core/http/OpenApi/openApiDocument.ts +6 -2
- package/core/http/Routing/RouterBuilder.ts +8 -4
- package/core/http/options.ts +14 -3
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/kernel/Kernel.ts +23 -2
- package/core/kernel/KernelBuilder.ts +5 -3
- package/library/terminal/messages.ts +2 -2
- package/package.json +1 -1
package/cli/build.ts
CHANGED
|
@@ -14,9 +14,13 @@ export interface BuildOptions {
|
|
|
14
14
|
readonly outfile?: string;
|
|
15
15
|
/** Let the executable read `.env` files from its working directory (off by default). */
|
|
16
16
|
readonly dotenv?: boolean;
|
|
17
|
+
/** Another platform, `linux-x64`: `bun build --target=bun-linux-x64`. */
|
|
18
|
+
readonly platform?: string;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export interface CompileOptions {
|
|
22
|
+
/** Cross-compile for `bun-<platform>`; Bun downloads that runtime once. */
|
|
23
|
+
readonly platform?: string;
|
|
20
24
|
/** Let the executable read `.env`, `.env.local`, `.env.<NODE_ENV>` from its working directory. */
|
|
21
25
|
readonly dotenv?: boolean;
|
|
22
26
|
}
|
|
@@ -149,10 +153,11 @@ export async function runBuild(cwd: string, options: BuildOptions, codegen: Code
|
|
|
149
153
|
const s = ui.out;
|
|
150
154
|
const t = s.t;
|
|
151
155
|
const startedAt = performance.now();
|
|
152
|
-
const outfile = options.bin ? path.resolve(cwd, options.outfile ?? path.join("bin", await
|
|
156
|
+
const outfile = options.bin ? path.resolve(cwd, options.outfile ?? path.join("bin", await defaultBinaryName(cwd, options.platform))) : undefined;
|
|
153
157
|
commandHeader(s, "build", cwd, [
|
|
154
158
|
s.kv(t.meta.target, s.text(options.bin ? "executable" : "typecheck")),
|
|
155
159
|
...(outfile === undefined ? [] : [s.kv(t.meta.out, s.text(path.relative(cwd, outfile)))]),
|
|
160
|
+
...(options.platform === undefined ? [] : [s.kv(t.meta.platform, s.text(options.platform))]),
|
|
156
161
|
]);
|
|
157
162
|
s.section(t.section.build);
|
|
158
163
|
const generated = await codegen(cwd, "all", { ui });
|
|
@@ -182,7 +187,7 @@ export async function runBuild(cwd: string, options: BuildOptions, codegen: Code
|
|
|
182
187
|
s.section(t.section.binary);
|
|
183
188
|
const stopCompile = s.progress(t.step.compile);
|
|
184
189
|
const compileStarted = performance.now();
|
|
185
|
-
const compiled = await compileBinaryAsync(bun, await projectEntry(cwd), outfile, { dotenv: options.dotenv });
|
|
190
|
+
const compiled = await compileBinaryAsync(bun, await projectEntry(cwd), outfile, { dotenv: options.dotenv, platform: options.platform });
|
|
186
191
|
stopCompile();
|
|
187
192
|
const compileMs = formatDuration(performance.now() - compileStarted);
|
|
188
193
|
const relative = path.relative(cwd, outfile);
|
|
@@ -263,9 +268,10 @@ export async function compileBinaryAsync(bun: string, entry: string, outfile: st
|
|
|
263
268
|
}
|
|
264
269
|
}
|
|
265
270
|
|
|
266
|
-
function compileArgs(bun: string, entry: string, outfile: string, options: CompileOptions): string[] {
|
|
271
|
+
export function compileArgs(bun: string, entry: string, outfile: string, options: CompileOptions): string[] {
|
|
267
272
|
const autoload = ["--no-compile-autoload-bunfig", options.dotenv === true ? "--compile-autoload-dotenv" : "--no-compile-autoload-dotenv"];
|
|
268
|
-
|
|
273
|
+
const target = options.platform === undefined ? [] : [`--target=bun-${options.platform}`];
|
|
274
|
+
return [bun, "build", "--compile", ...target, ...autoload, path.resolve(entry), "--outfile", path.resolve(outfile)];
|
|
269
275
|
}
|
|
270
276
|
|
|
271
277
|
function removeWorkDir(workDir: string): void {
|
|
@@ -287,6 +293,12 @@ async function projectEntry(cwd: string): Promise<string> {
|
|
|
287
293
|
return path.resolve(cwd, entry);
|
|
288
294
|
}
|
|
289
295
|
|
|
296
|
+
/** `bin/<name>`, or `bin/<name>-<platform>` for another platform, so the local build is kept. */
|
|
297
|
+
async function defaultBinaryName(cwd: string, platform: string | undefined): Promise<string> {
|
|
298
|
+
const name = await binaryName(cwd);
|
|
299
|
+
return platform === undefined ? name : `${name}-${platform}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
290
302
|
async function binaryName(cwd: string): Promise<string> {
|
|
291
303
|
const manifest = Bun.file(path.join(cwd, "package.json"));
|
|
292
304
|
const name = await manifest.exists() ? (await manifest.json() as { name?: unknown }).name : undefined;
|
package/cli/main.ts
CHANGED
|
@@ -20,6 +20,8 @@ export const USAGE = `Usage:
|
|
|
20
20
|
bazis build Codegen and typecheck
|
|
21
21
|
bazis build --bin [--outfile <path>] Also compile a standalone executable (default bin/<name>)
|
|
22
22
|
bazis build --bin --dotenv The executable also reads .env files from its working directory
|
|
23
|
+
bazis build --bin --platform <os-arch> For another platform, e.g. linux-x64, linux-arm64-musl
|
|
24
|
+
(default output bin/<name>-<os-arch>)
|
|
23
25
|
bazis --help
|
|
24
26
|
bazis --version Print the bazis version (also -v)
|
|
25
27
|
g can also be written as generate.
|
|
@@ -130,7 +132,7 @@ export async function runCli(argv: readonly string[], runtime: CliRuntime = defa
|
|
|
130
132
|
if (parsed.kind === "codegen") return await runCodegenCommand(cwd, parsed.target, codegen, ui);
|
|
131
133
|
if (parsed.kind === "dev") return await runDev(cwd, codegen, ui, { watch: parsed.watch });
|
|
132
134
|
if (parsed.kind === "test") return await runTest(cwd, parsed.args, codegen, ui);
|
|
133
|
-
if (parsed.kind === "build") return await runBuild(cwd, { bin: parsed.bin, outfile: parsed.outfile, dotenv: parsed.dotenv }, codegen, ui);
|
|
135
|
+
if (parsed.kind === "build") return await runBuild(cwd, { bin: parsed.bin, outfile: parsed.outfile, dotenv: parsed.dotenv, platform: parsed.platform }, codegen, ui);
|
|
134
136
|
if (parsed.kind === "new") return await runNew(parsed, ui);
|
|
135
137
|
// Module registration parses the host module with the TypeScript API: load it
|
|
136
138
|
// only after switching TypeScript 7 to @typescript/typescript6.
|
package/cli/parseCli.ts
CHANGED
|
@@ -26,14 +26,16 @@ export type ParseCliResult =
|
|
|
26
26
|
| { readonly kind: "codegen"; readonly target?: string }
|
|
27
27
|
| { readonly kind: "dev"; readonly watch: boolean }
|
|
28
28
|
| { readonly kind: "test"; readonly args: readonly string[] }
|
|
29
|
-
| { readonly kind: "build"; readonly bin: boolean; readonly outfile?: string; readonly dotenv: boolean }
|
|
29
|
+
| { readonly kind: "build"; readonly bin: boolean; readonly outfile?: string; readonly dotenv: boolean; readonly platform?: string }
|
|
30
30
|
| { readonly kind: "error"; readonly message: string };
|
|
31
31
|
|
|
32
32
|
const GENERATOR_ALIASES = new Map<string, "module" | "pack">([
|
|
33
33
|
["module", "module"], ["m", "module"],
|
|
34
34
|
["pack", "pack"], ["p", "pack"], ["module-pack", "pack"],
|
|
35
35
|
]);
|
|
36
|
-
const VALUE_OPTIONS = new Set(["--modules-root", "--app-module", "--pack", "--parts", "--target", "--path", "--framework", "--outfile"]);
|
|
36
|
+
const VALUE_OPTIONS = new Set(["--modules-root", "--app-module", "--pack", "--parts", "--target", "--path", "--framework", "--outfile", "--platform"]);
|
|
37
|
+
/** Platforms of `bun build --compile --target=bun-<platform>`. */
|
|
38
|
+
export const BINARY_PLATFORM = /^(linux|darwin|windows)-(x64|arm64)(-musl)?(-baseline|-modern)?$/;
|
|
37
39
|
const FLAG_OPTIONS = new Set(["--no-register", "--force", "--full", "--enterprise", "--minimal", "--empty", "--dry-run", "--no-codegen", "--link-framework", "--vendor", "--bin", "--watch", "--dotenv"]);
|
|
38
40
|
|
|
39
41
|
/** Parsing is pure: help and invalid input can never start generation. */
|
|
@@ -91,15 +93,20 @@ export function parseCliArgs(argv: readonly string[]): ParseCliResult {
|
|
|
91
93
|
}
|
|
92
94
|
if (options.has("--watch")) return error("--watch is only supported by dev.");
|
|
93
95
|
if (command === "build") {
|
|
94
|
-
if (positional.length !== 1) return error("Use: bazis build [--bin [--outfile <path>] [--dotenv]]");
|
|
96
|
+
if (positional.length !== 1) return error("Use: bazis build [--bin [--outfile <path>] [--platform <os-arch>] [--dotenv]]");
|
|
95
97
|
for (const option of options.keys()) {
|
|
96
|
-
if (option !== "--bin" && option !== "--outfile" && option !== "--dotenv") return error(`Option ${option} is not supported by build.`);
|
|
98
|
+
if (option !== "--bin" && option !== "--outfile" && option !== "--dotenv" && option !== "--platform") return error(`Option ${option} is not supported by build.`);
|
|
97
99
|
}
|
|
98
100
|
if (options.has("--outfile") && !options.has("--bin")) return error("--outfile requires --bin.");
|
|
99
101
|
if (options.has("--dotenv") && !options.has("--bin")) return error("--dotenv requires --bin.");
|
|
100
|
-
|
|
102
|
+
if (options.has("--platform") && !options.has("--bin")) return error("--platform requires --bin.");
|
|
103
|
+
const platform = options.get("--platform");
|
|
104
|
+
if (platform !== undefined && !BINARY_PLATFORM.test(platform)) {
|
|
105
|
+
return error(`Unknown platform: ${platform}. Use <os>-<arch>[-musl]: linux-x64, linux-arm64, linux-x64-musl, linux-arm64-musl, darwin-arm64, darwin-x64, windows-x64.`);
|
|
106
|
+
}
|
|
107
|
+
return { kind: "build", bin: options.has("--bin"), outfile: options.get("--outfile"), dotenv: options.has("--dotenv"), ...(platform === undefined ? {} : { platform }) };
|
|
101
108
|
}
|
|
102
|
-
if (options.has("--bin") || options.has("--outfile") || options.has("--dotenv")) return error("--bin, --outfile and --dotenv are only supported by build.");
|
|
109
|
+
if (options.has("--bin") || options.has("--outfile") || options.has("--dotenv") || options.has("--platform")) return error("--bin, --outfile, --platform and --dotenv are only supported by build.");
|
|
103
110
|
if (command === "codegen") {
|
|
104
111
|
if (positional.length !== 1) return error("Use: bazis codegen [--target <name|all>]");
|
|
105
112
|
for (const option of options.keys()) {
|
package/core/app/messages.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import { EN_HTTP_MESSAGES, RU_HTTP_MESSAGES, formatMessage, type HttpMessageKey, type Localize } from "../http/Errors/messages";
|
|
1
|
+
import { EN_HTTP_MESSAGES, RU_HTTP_MESSAGES, formatMessage, type HttpMessageKey, type LanguageSelection, type Localize, type SelectedLanguage } from "../http/Errors/messages";
|
|
2
2
|
import { KernelError } from "../kernel";
|
|
3
3
|
import { EN_VALIDATION_MESSAGES, MessageRegistry, RU_VALIDATION_MESSAGES, type ValidationCode } from "../../library/validation";
|
|
4
4
|
|
|
5
5
|
/** Language of the texts the framework writes into responses. */
|
|
6
6
|
export type AppLanguage = "en" | "ru";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Several response languages: each request gets the best match of its
|
|
10
|
+
* `Accept-Language` among `supported`, or `default`.
|
|
11
|
+
*/
|
|
12
|
+
export interface AppLanguageOptions {
|
|
13
|
+
readonly default: AppLanguage;
|
|
14
|
+
readonly supported: readonly AppLanguage[];
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
/**
|
|
9
18
|
* Own texts for framework messages, by key: HTTP texts (`notFound`,
|
|
10
19
|
* `malformedJson`, …), validation codes (`required`, `minLength`, …),
|
|
@@ -42,3 +51,49 @@ export function createLocalize(language: AppLanguage = "en", messages: AppMessag
|
|
|
42
51
|
return text === undefined ? undefined : formatMessage(text, params, template);
|
|
43
52
|
};
|
|
44
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The response language of every request: one language, or the best match of
|
|
57
|
+
* `Accept-Language` (`ru-RU,ru;q=0.9,en;q=0.8`) among `supported`.
|
|
58
|
+
*/
|
|
59
|
+
export function createLanguages(option: AppLanguage | AppLanguageOptions = "en", messages: AppMessages = {}): LanguageSelection {
|
|
60
|
+
if (typeof option === "string") {
|
|
61
|
+
const single: SelectedLanguage = { language: option, localize: createLocalize(option, messages) };
|
|
62
|
+
return { negotiated: false, select: () => single };
|
|
63
|
+
}
|
|
64
|
+
const supported = [...new Set(option.supported)];
|
|
65
|
+
if (supported.length === 0) throw new KernelError("runApp: language.supported needs at least one language.");
|
|
66
|
+
if (!supported.includes(option.default)) {
|
|
67
|
+
throw new KernelError(`runApp: language.default "${String(option.default)}" must be one of language.supported (${supported.join(", ")}).`);
|
|
68
|
+
}
|
|
69
|
+
const selected = new Map<string, SelectedLanguage>(supported.map((language) => [language, { language, localize: createLocalize(language, messages) }]));
|
|
70
|
+
const fallback = selected.get(option.default)!;
|
|
71
|
+
return {
|
|
72
|
+
negotiated: supported.length > 1,
|
|
73
|
+
select(acceptLanguage) {
|
|
74
|
+
const language = negotiateLanguage(acceptLanguage, supported);
|
|
75
|
+
return language === undefined ? fallback : selected.get(language)!;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The supported language with the highest `q` in `Accept-Language`; ties keep the header order. */
|
|
81
|
+
export function negotiateLanguage(header: string | null | undefined, supported: readonly string[]): string | undefined {
|
|
82
|
+
if (header === null || header === undefined || header.length > 1024) return undefined;
|
|
83
|
+
let best: { language: string; q: number } | undefined;
|
|
84
|
+
for (const part of header.split(",").slice(0, 32)) {
|
|
85
|
+
const [range = "", ...parameters] = part.trim().split(";");
|
|
86
|
+
const tag = range.trim().toLowerCase();
|
|
87
|
+
if (tag.length === 0) continue;
|
|
88
|
+
let q = 1;
|
|
89
|
+
for (const parameter of parameters) {
|
|
90
|
+
const [name, value] = parameter.trim().split("=");
|
|
91
|
+
if (name?.trim() === "q") q = Number(value);
|
|
92
|
+
}
|
|
93
|
+
if (!Number.isFinite(q) || q <= 0 || q > 1) continue;
|
|
94
|
+
const primary = tag === "*" ? undefined : tag.split("-")[0];
|
|
95
|
+
const language = primary === undefined ? undefined : supported.find((candidate) => candidate === primary);
|
|
96
|
+
if (language !== undefined && (best === undefined || q > best.q)) best = { language, q };
|
|
97
|
+
}
|
|
98
|
+
return best?.language;
|
|
99
|
+
}
|
package/core/app/runApp.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { missingCacheModuleCheck } from "../cache/http/missingCacheModuleCheck";
|
|
|
13
13
|
import { httpModule, useModelValidator, type HttpModuleOptions, type ModelValidator } from "../http";
|
|
14
14
|
import { grpcModule, type GrpcModuleOptions } from "../grpc";
|
|
15
15
|
import { buildHttpOpenApiDocument } from "../http/OpenApi/openApiDocument";
|
|
16
|
+
import { defaultAuthorizeChecks } from "../http/Authorization/metadata";
|
|
16
17
|
import { KernelBuilder, Bazis, KernelError, type KernelOptions, type ValidatableConfig } from "../kernel";
|
|
17
18
|
import { websocketModule, type WebSocketOptions } from "../websocket";
|
|
18
19
|
import {
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
type UiProfileV1,
|
|
21
22
|
} from "../../library/ui";
|
|
22
23
|
import { modelValidatorAdapter } from "../../library/validation";
|
|
23
|
-
import {
|
|
24
|
+
import { createLanguages, type AppLanguage, type AppLanguageOptions, type AppMessages } from "./messages";
|
|
24
25
|
import { useCodexLanguage } from "../infra/connectors/codex/contracts";
|
|
25
26
|
import {
|
|
26
27
|
type RunAppUiOptions,
|
|
@@ -65,8 +66,16 @@ export interface RunAppOptions {
|
|
|
65
66
|
* ```ts
|
|
66
67
|
* await runApp(AppModule, { language: "ru", http: { port: 3000 } });
|
|
67
68
|
* ```
|
|
69
|
+
*
|
|
70
|
+
* Several languages — each request gets the best match of its
|
|
71
|
+
* `Accept-Language`, or `default`; such responses carry `Content-Language`
|
|
72
|
+
* and `Vary: Accept-Language`, and `ctx.language` names the choice:
|
|
73
|
+
*
|
|
74
|
+
* ```ts
|
|
75
|
+
* await runApp(AppModule, { language: { default: "ru", supported: ["ru", "en"] } });
|
|
76
|
+
* ```
|
|
68
77
|
*/
|
|
69
|
-
readonly language?: AppLanguage;
|
|
78
|
+
readonly language?: AppLanguage | AppLanguageOptions;
|
|
70
79
|
/**
|
|
71
80
|
* Own texts for single framework messages, on top of `language`:
|
|
72
81
|
* `{ notFound: "Нет такой страницы", required: "Заполните {property}" }`.
|
|
@@ -204,6 +213,7 @@ function buildUiOpenApiDocument(
|
|
|
204
213
|
controllers: collectModuleControllers([root]),
|
|
205
214
|
globalPrefix: http.prefix,
|
|
206
215
|
versioning: http.versioning,
|
|
216
|
+
authorize: defaultAuthorizeChecks(http.authorize),
|
|
207
217
|
title: docs?.title ?? "Bazis UI profiles",
|
|
208
218
|
version: docs?.version ?? "1.0.0",
|
|
209
219
|
});
|
|
@@ -290,8 +300,8 @@ export async function runApp(root: BazisModuleRef, options: RunAppOptions = {}):
|
|
|
290
300
|
*/
|
|
291
301
|
export function composeApp(root: BazisModuleRef, options: RunAppOptions = {}): BazisModuleRef {
|
|
292
302
|
// Checked even without HTTP: an unknown language is a startup error.
|
|
293
|
-
const
|
|
294
|
-
useCodexLanguage(options.language ?? "en");
|
|
303
|
+
const languages = createLanguages(options.language, options.messages);
|
|
304
|
+
useCodexLanguage(typeof options.language === "object" ? options.language.default : options.language ?? "en");
|
|
295
305
|
// Infrastructure and cache are global modules; add them to the graph next to the feature root.
|
|
296
306
|
const globals: BazisModuleRef[] = [];
|
|
297
307
|
if (options.infra !== undefined) {
|
|
@@ -329,7 +339,7 @@ export function composeApp(root: BazisModuleRef, options: RunAppOptions = {}): B
|
|
|
329
339
|
options.http,
|
|
330
340
|
);
|
|
331
341
|
const httpRoot = httpModule({
|
|
332
|
-
|
|
342
|
+
languages,
|
|
333
343
|
...options.http,
|
|
334
344
|
validator,
|
|
335
345
|
imports: [httpAppRoot],
|
|
@@ -2,7 +2,7 @@ import { JwtError } from "../../../library/jwt/errors";
|
|
|
2
2
|
import { ForbiddenError, UnauthorizedError } from "../Errors/HttpError";
|
|
3
3
|
import type { HttpMiddleware } from "../Middleware/types";
|
|
4
4
|
import type { RouteMiddlewareComposer } from "../options";
|
|
5
|
-
import {
|
|
5
|
+
import { routeAuthorizeChecks, type AuthorizeCheck } from "./metadata";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Built-in composer for `@Authorize`. The HTTP server puts it first among
|
|
@@ -12,14 +12,13 @@ import { resolveAuthorizeMeta } from "./metadata";
|
|
|
12
12
|
* `UnauthorizedError` → `401`). A `JwtError` (missing, malformed, expired or
|
|
13
13
|
* forged token) escaping a check is a 401 as well, not a server error.
|
|
14
14
|
*/
|
|
15
|
-
export function createAuthorizeComposer(): RouteMiddlewareComposer {
|
|
15
|
+
export function createAuthorizeComposer(defaults: readonly AuthorizeCheck[] = []): RouteMiddlewareComposer {
|
|
16
16
|
return (controllerClass, methodName) => {
|
|
17
|
-
const
|
|
18
|
-
if (
|
|
17
|
+
const checks = routeAuthorizeChecks(controllerClass, methodName, defaults);
|
|
18
|
+
if (checks === undefined) {
|
|
19
19
|
return [];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const { checks } = meta.authorize;
|
|
23
22
|
const middleware: HttpMiddleware = async (ctx, next) => {
|
|
24
23
|
for (const check of checks) {
|
|
25
24
|
let allowed: boolean;
|
|
@@ -177,3 +177,20 @@ export function resolveAuthorizeMeta(ctor: object, methodName: string | symbol):
|
|
|
177
177
|
? { allowAnonymous: false }
|
|
178
178
|
: { allowAnonymous: false, authorize: { checks } };
|
|
179
179
|
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The checks a route runs: the server's default checks (`runApp({ http:
|
|
183
|
+
* { authorize } })`) first, then the route's own `@Authorize`; each function
|
|
184
|
+
* once. `undefined` — the route is open: `@AllowAnonymous`, or no checks at all.
|
|
185
|
+
*/
|
|
186
|
+
export function routeAuthorizeChecks(ctor: object, methodName: string | symbol, defaults: readonly AuthorizeCheck[] = []): readonly AuthorizeCheck[] | undefined {
|
|
187
|
+
const meta = resolveAuthorizeMeta(ctor, methodName);
|
|
188
|
+
if (meta.allowAnonymous) return undefined;
|
|
189
|
+
const checks = [...new Set([...defaults, ...(meta.authorize?.checks ?? [])])];
|
|
190
|
+
return checks.length === 0 ? undefined : checks;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** `authorize` of the HTTP options as a list. */
|
|
194
|
+
export function defaultAuthorizeChecks(option: AuthorizeCheck | readonly AuthorizeCheck[] | undefined): readonly AuthorizeCheck[] {
|
|
195
|
+
return option === undefined ? [] : typeof option === "function" ? [option] : option;
|
|
196
|
+
}
|
|
@@ -8,6 +8,27 @@ export type MessageParams = Readonly<Record<string, unknown>>;
|
|
|
8
8
|
*/
|
|
9
9
|
export type Localize = (key: string, params: MessageParams) => string | undefined;
|
|
10
10
|
|
|
11
|
+
/** The response language of one request and its texts. */
|
|
12
|
+
export interface SelectedLanguage {
|
|
13
|
+
readonly language: string;
|
|
14
|
+
readonly localize: Localize;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Picks the response language of a request. `negotiated`: several languages,
|
|
19
|
+
* chosen by `Accept-Language`; responses whose text the framework wrote then
|
|
20
|
+
* carry `Content-Language` and `Vary: Accept-Language`.
|
|
21
|
+
*/
|
|
22
|
+
export interface LanguageSelection {
|
|
23
|
+
readonly negotiated: boolean;
|
|
24
|
+
select(acceptLanguage: string | null | undefined): SelectedLanguage;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Headers of a framework-written response in a negotiated language. */
|
|
28
|
+
export function languageHeaders(selection: LanguageSelection | undefined, selected: SelectedLanguage | undefined): [string, string][] {
|
|
29
|
+
return selection?.negotiated === true && selected !== undefined ? [["content-language", selected.language], ["vary", "Accept-Language"]] : [];
|
|
30
|
+
}
|
|
31
|
+
|
|
11
32
|
/** A message key and parameters carried by an error, rendered at the response. */
|
|
12
33
|
export interface LocalizedMessage {
|
|
13
34
|
readonly key: string;
|
|
@@ -46,6 +46,12 @@ export class HttpContext {
|
|
|
46
46
|
readonly clientIp?: string,
|
|
47
47
|
/** Validator captured by the owning HTTP server. */
|
|
48
48
|
readonly modelValidator?: ModelValidator,
|
|
49
|
+
/**
|
|
50
|
+
* Response language of this request: `runApp({ language })`, or the best
|
|
51
|
+
* match of `Accept-Language` when several languages are supported.
|
|
52
|
+
* `undefined` without `language`.
|
|
53
|
+
*/
|
|
54
|
+
readonly language?: string,
|
|
49
55
|
) {}
|
|
50
56
|
|
|
51
57
|
get method(): string {
|
package/core/http/HttpServer.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { HttpMiddleware } from "./Middleware/types";
|
|
|
15
15
|
import { prepareOpenApiDocs, serveOpenApiDocs } from "./OpenApi/openApiDocs";
|
|
16
16
|
import type { PreparedOpenApiDocs } from "../../library/openapi";
|
|
17
17
|
import { RouterBuilder } from "./Routing/RouterBuilder";
|
|
18
|
+
import { defaultAuthorizeChecks } from "./Authorization/metadata";
|
|
18
19
|
import type { Router } from "./Routing/Router";
|
|
19
20
|
import { parseRequestPath } from "./Routing/template";
|
|
20
21
|
import { assertVersioningOptions, createVersionReader, type VersionReader } from "./Versioning/VersionReader";
|
|
@@ -22,7 +23,7 @@ import { HTTP_ERROR_HOOK, ROUTE_MIDDLEWARE_COMPOSER, SERVER_MIDDLEWARE, sortByOr
|
|
|
22
23
|
import type { HealthEndpointOptions, RouteMiddlewareComposer, HttpModuleOptions, HttpServerOptions } from "./options";
|
|
23
24
|
import { WEBSOCKET_UPGRADE, type WebSocketUpgrade } from "./WebSocketUpgrade";
|
|
24
25
|
import { HttpSetupError } from "./Errors/HttpError";
|
|
25
|
-
import { EN_HTTP_MESSAGES, formatMessage, type HttpMessageKey, type MessageParams } from "./Errors/messages";
|
|
26
|
+
import { EN_HTTP_MESSAGES, formatMessage, languageHeaders, type HttpMessageKey, type MessageParams, type SelectedLanguage } from "./Errors/messages";
|
|
26
27
|
import { getModelValidator, type ModelValidator } from "./Binding/modelValidator";
|
|
27
28
|
|
|
28
29
|
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
@@ -167,7 +168,7 @@ export class HttpServer implements HostedService {
|
|
|
167
168
|
errorHandler({
|
|
168
169
|
exposeDetails,
|
|
169
170
|
...(errorLogger ? { logger: errorLogger } : {}),
|
|
170
|
-
...(this.options.
|
|
171
|
+
...(this.options.languages ? { languages: this.options.languages } : {}),
|
|
171
172
|
...this.options.errorHandler,
|
|
172
173
|
onUnexpectedError: this.collectErrorHook(),
|
|
173
174
|
}),
|
|
@@ -177,11 +178,13 @@ export class HttpServer implements HostedService {
|
|
|
177
178
|
this.globalCors = this.options.cors || serverMiddleware.map(corsOptionsOf).find((options) => options !== undefined);
|
|
178
179
|
serverChain.push(...serverMiddleware.filter((middleware) => !isCorrelationIdMiddleware(middleware)));
|
|
179
180
|
|
|
181
|
+
const defaultChecks = defaultAuthorizeChecks(this.options.authorize);
|
|
180
182
|
const builder = new RouterBuilder(
|
|
181
183
|
serverChain,
|
|
182
184
|
this.options.prefix,
|
|
183
185
|
this.options.versioning,
|
|
184
186
|
this.collectRouteComposer(),
|
|
187
|
+
defaultChecks,
|
|
185
188
|
);
|
|
186
189
|
this.router = builder.build(this.options.controllers);
|
|
187
190
|
const routes = builder.routes;
|
|
@@ -192,6 +195,7 @@ export class HttpServer implements HostedService {
|
|
|
192
195
|
globalPrefix: this.options.prefix,
|
|
193
196
|
versioning: this.options.versioning,
|
|
194
197
|
environment: this.resolver.tryResolve(Environment),
|
|
198
|
+
authorize: defaultChecks,
|
|
195
199
|
});
|
|
196
200
|
|
|
197
201
|
this.webSocket = this.resolver.tryResolve(WEBSOCKET_UPGRADE);
|
|
@@ -204,7 +208,7 @@ export class HttpServer implements HostedService {
|
|
|
204
208
|
await ws.initialize();
|
|
205
209
|
this.server = await this.startBunServer((listen) => Bun.serve({
|
|
206
210
|
...listen,
|
|
207
|
-
error: (error) => this.transportError(error),
|
|
211
|
+
error: (error) => this.transportError(error, undefined),
|
|
208
212
|
websocket: ws.createBunHandler(),
|
|
209
213
|
fetch: async (request, server) => {
|
|
210
214
|
// null -> not for the WS runtime, fall through to HTTP.
|
|
@@ -230,7 +234,7 @@ export class HttpServer implements HostedService {
|
|
|
230
234
|
Bun.serve({
|
|
231
235
|
...listen,
|
|
232
236
|
fetch: (request, server) => this.dispatch(request, server.requestIP(request)?.address),
|
|
233
|
-
error: (error) => this.transportError(error),
|
|
237
|
+
error: (error) => this.transportError(error, undefined),
|
|
234
238
|
}),
|
|
235
239
|
);
|
|
236
240
|
}
|
|
@@ -380,29 +384,44 @@ export class HttpServer implements HostedService {
|
|
|
380
384
|
} catch (error) {
|
|
381
385
|
// Covers failures before a request context can be created. Normal route
|
|
382
386
|
// and short-circuit failures are handled by the configured error boundary.
|
|
383
|
-
response = this.prepareClientResponse(this.transportError(error), request);
|
|
387
|
+
response = this.prepareClientResponse(this.transportError(error, request), request);
|
|
384
388
|
}
|
|
385
389
|
// HEAD must echo the GET response's status/headers without a body.
|
|
386
390
|
return request.method === "HEAD" ? stripBody(response) : response;
|
|
387
391
|
}
|
|
388
392
|
|
|
389
|
-
/**
|
|
390
|
-
private
|
|
393
|
+
/** The response language of a request; `undefined` without `runApp({ language })`. */
|
|
394
|
+
private selected(request: Request | undefined): SelectedLanguage | undefined {
|
|
395
|
+
try {
|
|
396
|
+
return this.options.languages?.select(request?.headers.get("accept-language"));
|
|
397
|
+
} catch {
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** A response text the server writes itself, in the language of the request. */
|
|
403
|
+
private text(key: HttpMessageKey, request: Request | undefined, params: MessageParams = {}): string {
|
|
391
404
|
const english = formatMessage(EN_HTTP_MESSAGES[key], params);
|
|
392
405
|
try {
|
|
393
|
-
return this.
|
|
406
|
+
return this.selected(request)?.localize(key, params) ?? english;
|
|
394
407
|
} catch {
|
|
395
408
|
return english;
|
|
396
409
|
}
|
|
397
410
|
}
|
|
398
411
|
|
|
399
|
-
|
|
412
|
+
/** `{ error, …extra }` the server writes itself, with the language headers when negotiated. */
|
|
413
|
+
private errorJson(request: Request | undefined, status: number, key: HttpMessageKey, extra: Record<string, unknown> = {}, headers: Record<string, string> = {}): Response {
|
|
414
|
+
const language = Object.fromEntries(languageHeaders(this.options.languages, this.selected(request)));
|
|
415
|
+
return json(status, { error: this.text(key, request), ...extra }, { ...headers, ...language });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private transportError(error: unknown, request: Request | undefined): Response {
|
|
400
419
|
try {
|
|
401
420
|
console.error("[http] transport failed:", redactSensitive(error));
|
|
402
421
|
} catch {
|
|
403
422
|
// Best-effort diagnostics only.
|
|
404
423
|
}
|
|
405
|
-
const response =
|
|
424
|
+
const response = this.errorJson(request, 500, "internalServerError");
|
|
406
425
|
if (this.options.securityHeaders !== false) {
|
|
407
426
|
applySecurityHeaders(response, this.options.securityHeaders ?? {});
|
|
408
427
|
}
|
|
@@ -446,9 +465,9 @@ export class HttpServer implements HostedService {
|
|
|
446
465
|
if (!segments) {
|
|
447
466
|
if (this.maxBodyBytes !== undefined && exceedsBodyLimit(request, this.maxBodyBytes)) {
|
|
448
467
|
return this.runShortCircuit(request, url, clientIp, () =>
|
|
449
|
-
|
|
468
|
+
this.errorJson(request, 413, "payloadTooLarge", { details: { maxBytes: this.maxBodyBytes } }));
|
|
450
469
|
}
|
|
451
|
-
return this.runShortCircuit(request, url, clientIp, () =>
|
|
470
|
+
return this.runShortCircuit(request, url, clientIp, () => this.errorJson(request, 400, "malformedPath"));
|
|
452
471
|
}
|
|
453
472
|
|
|
454
473
|
const match = this.router!.match(request.method, segments, this.readVersion(request, url));
|
|
@@ -457,18 +476,18 @@ export class HttpServer implements HostedService {
|
|
|
457
476
|
: this.maxBodyBytes;
|
|
458
477
|
if (effectiveMaxBodyBytes !== undefined && exceedsBodyLimit(request, effectiveMaxBodyBytes)) {
|
|
459
478
|
return this.runShortCircuit(request, url, clientIp, () =>
|
|
460
|
-
|
|
479
|
+
this.errorJson(request, 413, "payloadTooLarge", { details: { maxBytes: effectiveMaxBodyBytes } }));
|
|
461
480
|
}
|
|
462
481
|
if (match.kind === "not-found") {
|
|
463
|
-
return this.runShortCircuit(request, url, clientIp, () =>
|
|
482
|
+
return this.runShortCircuit(request, url, clientIp, () => this.errorJson(request, 404, "notFound"));
|
|
464
483
|
}
|
|
465
484
|
if (match.kind === "method-not-allowed") {
|
|
466
485
|
return this.runShortCircuit(request, url, clientIp, () =>
|
|
467
|
-
|
|
486
|
+
this.errorJson(request, 405, "methodNotAllowed", {}, { allow: match.allow.join(", ") }));
|
|
468
487
|
}
|
|
469
488
|
if (match.kind === "unsupported-version") {
|
|
470
489
|
return this.runShortCircuit(request, url, clientIp, () =>
|
|
471
|
-
|
|
490
|
+
this.errorJson(request, 400, "unsupportedApiVersion", { details: { supported: match.supported } }));
|
|
472
491
|
}
|
|
473
492
|
|
|
474
493
|
const scope = this.provider.createScope();
|
|
@@ -481,13 +500,14 @@ export class HttpServer implements HostedService {
|
|
|
481
500
|
effectiveMaxBodyBytes,
|
|
482
501
|
clientIp,
|
|
483
502
|
this.modelValidator,
|
|
503
|
+
this.selected(request)?.language,
|
|
484
504
|
);
|
|
485
505
|
let scopeTransferred = false;
|
|
486
506
|
try {
|
|
487
507
|
await runPipeline(match.action.chain, ctx);
|
|
488
508
|
// The error boundary always sets a response on failure; an empty
|
|
489
509
|
// response here means no middleware produced one.
|
|
490
|
-
const response = this.prepareClientResponse(ctx.response ??
|
|
510
|
+
const response = this.prepareClientResponse(ctx.response ?? this.errorJson(request, 404, "notFound"), request);
|
|
491
511
|
if (request.method === "HEAD") return stripBody(response);
|
|
492
512
|
if (!response.body) return response;
|
|
493
513
|
this.trackResponse(holdResponseScope(response, request.signal, () =>
|
|
@@ -525,6 +545,7 @@ export class HttpServer implements HostedService {
|
|
|
525
545
|
this.maxBodyBytes,
|
|
526
546
|
clientIp,
|
|
527
547
|
this.modelValidator,
|
|
548
|
+
this.selected(request)?.language,
|
|
528
549
|
);
|
|
529
550
|
const terminal: HttpMiddleware = async (context) => {
|
|
530
551
|
context.response = await produce();
|
|
@@ -532,7 +553,7 @@ export class HttpServer implements HostedService {
|
|
|
532
553
|
let scopeTransferred = false;
|
|
533
554
|
try {
|
|
534
555
|
await runPipeline([...this.shortCircuitChain, terminal], ctx);
|
|
535
|
-
const response = this.prepareClientResponse(ctx.response ??
|
|
556
|
+
const response = this.prepareClientResponse(ctx.response ?? this.errorJson(request, 500, "internalServerError"), request);
|
|
536
557
|
if (request.method === "HEAD") return stripBody(response);
|
|
537
558
|
if (!response.body) return response;
|
|
538
559
|
this.trackResponse(holdResponseScope(response, request.signal, () =>
|
|
@@ -3,7 +3,7 @@ import { redactSensitive, redactSensitiveText } from "../../../library/redaction
|
|
|
3
3
|
import { HttpClientError, HttpErrorCode } from "../../../library/http-client/errors";
|
|
4
4
|
import type { HttpContext } from "../HttpContext/HttpContext";
|
|
5
5
|
import { HttpError, ModelValidationError } from "../Errors/HttpError";
|
|
6
|
-
import { EN_HTTP_MESSAGES, STATUS_MESSAGE_KEYS, type HttpMessageKey, type Localize } from "../Errors/messages";
|
|
6
|
+
import { EN_HTTP_MESSAGES, STATUS_MESSAGE_KEYS, type HttpMessageKey, languageHeaders, type LanguageSelection, type Localize } from "../Errors/messages";
|
|
7
7
|
import { upstreamFailure } from "../Errors/upstreamFailure";
|
|
8
8
|
import type { HttpMiddleware } from "./types";
|
|
9
9
|
|
|
@@ -36,11 +36,11 @@ export interface ErrorHandlerOptions {
|
|
|
36
36
|
logger?: Logger;
|
|
37
37
|
/**
|
|
38
38
|
* Renders framework texts (`Not Found`, validation messages, …) in the
|
|
39
|
-
*
|
|
40
|
-
* Without it the texts stay English. Messages an application
|
|
41
|
-
* error itself are never changed.
|
|
39
|
+
* response language of each request; `runApp` builds it from `language` and
|
|
40
|
+
* `messages`. Without it the texts stay English. Messages an application
|
|
41
|
+
* passes to an error itself are never changed.
|
|
42
42
|
*/
|
|
43
|
-
|
|
43
|
+
languages?: LanguageSelection;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
function jsonResponse(status: number, body: unknown, extraHeaders?: readonly [string, string][]): Response {
|
|
@@ -111,13 +111,15 @@ function safelyReportUnexpected(
|
|
|
111
111
|
*/
|
|
112
112
|
export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware {
|
|
113
113
|
const log = resolveErrorLog(options);
|
|
114
|
-
const text = textRenderer(options.localize);
|
|
115
114
|
return async (ctx, next) => {
|
|
116
115
|
try {
|
|
117
116
|
await next();
|
|
118
117
|
} catch (error) {
|
|
118
|
+
const selected = options.languages?.select(ctx.header("accept-language"));
|
|
119
|
+
const text = textRenderer(selected?.localize);
|
|
120
|
+
const language = languageHeaders(options.languages, selected);
|
|
119
121
|
if (error instanceof HttpError) {
|
|
120
|
-
const extra: [string, string][] = [];
|
|
122
|
+
const extra: [string, string][] = [...language];
|
|
121
123
|
const allow = (error as { allow?: readonly string[] }).allow;
|
|
122
124
|
if (allow) {
|
|
123
125
|
extra.push(["allow", allow.join(", ")]);
|
|
@@ -159,8 +161,8 @@ export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware
|
|
|
159
161
|
// message must not reach a browser or a shared screenshot.
|
|
160
162
|
message: redactSensitiveText(error instanceof Error ? error.message : String(error)),
|
|
161
163
|
stack: error instanceof Error && error.stack !== undefined ? redactSensitiveText(error.stack) : undefined,
|
|
162
|
-
})
|
|
163
|
-
: safeJsonResponse(status, { error: title });
|
|
164
|
+
}, language)
|
|
165
|
+
: safeJsonResponse(status, { error: title }, language);
|
|
164
166
|
}
|
|
165
167
|
};
|
|
166
168
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AuthorizeCheck } from "../Authorization/metadata";
|
|
1
2
|
import type { Class } from "../../di";
|
|
2
3
|
import { Environment } from "../../kernel";
|
|
3
4
|
import {
|
|
@@ -15,6 +16,7 @@ interface PrepareOpenApiDocsInput {
|
|
|
15
16
|
readonly globalPrefix?: string;
|
|
16
17
|
readonly versioning?: ApiVersioningOptions;
|
|
17
18
|
readonly environment?: Environment;
|
|
19
|
+
readonly authorize?: readonly AuthorizeCheck[];
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
const DEFAULT_UI_PATH = "/docs";
|
|
@@ -38,6 +40,7 @@ export function prepareOpenApiDocs(input: PrepareOpenApiDocsInput): PreparedOpen
|
|
|
38
40
|
versioning: input.versioning,
|
|
39
41
|
title,
|
|
40
42
|
version,
|
|
43
|
+
...(input.authorize === undefined ? {} : { authorize: input.authorize }),
|
|
41
44
|
});
|
|
42
45
|
const json = JSON.stringify(document, null, 2);
|
|
43
46
|
return {
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type OpenApiSchema,
|
|
9
9
|
} from "../../../library/openapi";
|
|
10
10
|
import { optionsFromSchema } from "../../../library/jsonapi";
|
|
11
|
-
import { resolveAuthorizeMeta } from "../Authorization/metadata";
|
|
11
|
+
import { resolveAuthorizeMeta, routeAuthorizeChecks, type AuthorizeCheck } from "../Authorization/metadata";
|
|
12
12
|
import type { ParameterBinding } from "../Binding/bindings";
|
|
13
13
|
import { resolveGeneratedBindings } from "../Binding/autoBindings";
|
|
14
14
|
import { controllerMetaOf, type ActionMeta, type ControllerMeta } from "../Decorators/metadata";
|
|
@@ -23,6 +23,8 @@ export interface HttpOpenApiDocumentBuildInput {
|
|
|
23
23
|
readonly versioning?: ApiVersioningOptions;
|
|
24
24
|
readonly title: string;
|
|
25
25
|
readonly version: string;
|
|
26
|
+
/** Default checks of `runApp({ http: { authorize } })`: such routes are secured too. */
|
|
27
|
+
readonly authorize?: readonly AuthorizeCheck[];
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
/** Body of every error response: `{ "error": "...", "details": ... }` (see Middleware/errorHandler). */
|
|
@@ -80,6 +82,7 @@ function collectOpenApiOperations(
|
|
|
80
82
|
routeTemplate: joinPaths(meta.prefix, route.template),
|
|
81
83
|
versioning: input.versioning,
|
|
82
84
|
generated,
|
|
85
|
+
defaultChecks: input.authorize ?? [],
|
|
83
86
|
}));
|
|
84
87
|
}
|
|
85
88
|
}
|
|
@@ -98,6 +101,7 @@ interface CreateOperationInput {
|
|
|
98
101
|
readonly routeTemplate: string;
|
|
99
102
|
readonly versioning?: ApiVersioningOptions;
|
|
100
103
|
readonly generated: GeneratedOpenApiMetadata;
|
|
104
|
+
readonly defaultChecks: readonly AuthorizeCheck[];
|
|
101
105
|
}
|
|
102
106
|
|
|
103
107
|
function createOperation(input: CreateOperationInput): OpenApiCatalogOperation {
|
|
@@ -108,7 +112,7 @@ function createOperation(input: CreateOperationInput): OpenApiCatalogOperation {
|
|
|
108
112
|
const status = String(successStatus(input.httpMethod, input.action, generatedOperation?.status));
|
|
109
113
|
const responseSchema = generatedOperation?.response;
|
|
110
114
|
const contentType = input.action.produces ?? "application/json";
|
|
111
|
-
const authorized =
|
|
115
|
+
const authorized = routeAuthorizeChecks(input.controllerClass, input.methodName, input.defaultChecks) !== undefined;
|
|
112
116
|
|
|
113
117
|
return {
|
|
114
118
|
path: input.path,
|
|
@@ -10,7 +10,7 @@ import { corsOptionsOf } from "../Middleware/cors";
|
|
|
10
10
|
import { normalizeResult } from "../Results/normalize";
|
|
11
11
|
import type { ApiVersioningOptions, RouteMiddlewareComposer } from "../options";
|
|
12
12
|
import { createAuthorizeComposer } from "../Authorization/authorizeComposer";
|
|
13
|
-
import { resolveAuthorizeMeta } from "../Authorization/metadata";
|
|
13
|
+
import { resolveAuthorizeMeta, routeAuthorizeChecks, type AuthorizeCheck } from "../Authorization/metadata";
|
|
14
14
|
|
|
15
15
|
/** A registered route as the startup screen lists it. */
|
|
16
16
|
export interface RegisteredRoute {
|
|
@@ -39,7 +39,7 @@ interface ActionPipeline {
|
|
|
39
39
|
* precompiled per route — request dispatch performs no metadata work at all.
|
|
40
40
|
*/
|
|
41
41
|
export class RouterBuilder {
|
|
42
|
-
private readonly authorizeComposer
|
|
42
|
+
private readonly authorizeComposer: RouteMiddlewareComposer;
|
|
43
43
|
/** Every route of the last {@link build}, in registration order. */
|
|
44
44
|
readonly routes: RegisteredRoute[] = [];
|
|
45
45
|
|
|
@@ -49,7 +49,11 @@ export class RouterBuilder {
|
|
|
49
49
|
private readonly globalPrefix: string | undefined,
|
|
50
50
|
private readonly versioning: ApiVersioningOptions | undefined,
|
|
51
51
|
private readonly routeMiddlewareComposer?: RouteMiddlewareComposer,
|
|
52
|
-
|
|
52
|
+
/** Checks for every route that is not `@AllowAnonymous`. */
|
|
53
|
+
private readonly defaultChecks: readonly AuthorizeCheck[] = [],
|
|
54
|
+
) {
|
|
55
|
+
this.authorizeComposer = createAuthorizeComposer(defaultChecks);
|
|
56
|
+
}
|
|
53
57
|
|
|
54
58
|
build(controllers: readonly Class<object>[]): Router {
|
|
55
59
|
const router = new Router();
|
|
@@ -110,7 +114,7 @@ export class RouterBuilder {
|
|
|
110
114
|
method: declaration.httpMethod,
|
|
111
115
|
path: fullPath,
|
|
112
116
|
handler: routeAction.name,
|
|
113
|
-
open:
|
|
117
|
+
open: routeAuthorizeChecks(controllerClass, methodName, this.defaultChecks) === undefined,
|
|
114
118
|
anonymous: access.allowAnonymous === true,
|
|
115
119
|
}));
|
|
116
120
|
}
|
package/core/http/options.ts
CHANGED
|
@@ -2,12 +2,13 @@ import type { Class } from "../di";
|
|
|
2
2
|
import type { AccessLogOptions } from "./Middleware/accessLog";
|
|
3
3
|
import type { CorsOptions } from "./Middleware/cors";
|
|
4
4
|
import type { ErrorHandlerOptions } from "./Middleware/errorHandler";
|
|
5
|
-
import type {
|
|
5
|
+
import type { LanguageSelection } from "./Errors/messages";
|
|
6
6
|
import type { SecurityHeadersOptions } from "./Middleware/securityHeaders";
|
|
7
7
|
import type { HttpMiddleware } from "./Middleware/types";
|
|
8
8
|
import type { ActionMeta, ControllerMeta } from "./Decorators/metadata";
|
|
9
9
|
import type { OpenApiDocsOptions } from "../../library/openapi";
|
|
10
10
|
import type { ModelValidator } from "./Binding/modelValidator";
|
|
11
|
+
import type { AuthorizeCheck } from "./Authorization/metadata";
|
|
11
12
|
|
|
12
13
|
/** Built-in health endpoint (liveness/readiness probe). */
|
|
13
14
|
export interface HealthEndpointOptions {
|
|
@@ -60,11 +61,11 @@ export interface HttpModuleOptions {
|
|
|
60
61
|
/** Request-model validator captured by this HTTP server instance. */
|
|
61
62
|
readonly validator?: ModelValidator;
|
|
62
63
|
/**
|
|
63
|
-
*
|
|
64
|
+
* Response language of each request and its framework texts. `runApp`
|
|
64
65
|
* sets it from its `language` and `messages` options; without it the
|
|
65
66
|
* texts are English.
|
|
66
67
|
*/
|
|
67
|
-
readonly
|
|
68
|
+
readonly languages?: LanguageSelection;
|
|
68
69
|
/**
|
|
69
70
|
* Modules providing the controllers' dependencies. Controllers live inside
|
|
70
71
|
* the returned HttpModule, so their dependencies must be visible to it:
|
|
@@ -129,6 +130,16 @@ export interface HttpModuleOptions {
|
|
|
129
130
|
* title/version.
|
|
130
131
|
*/
|
|
131
132
|
readonly docs?: OpenApiDocsOptions | boolean;
|
|
133
|
+
/**
|
|
134
|
+
* Closed by default: these checks run on every route that is not
|
|
135
|
+
* `@AllowAnonymous`, before the route's own `@Authorize` checks
|
|
136
|
+
* (like ASP.NET Core's fallback policy). `/health` and the docs stay open.
|
|
137
|
+
*
|
|
138
|
+
* ```ts
|
|
139
|
+
* runApp(AppModule, { http: { authorize: signedIn } });
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
readonly authorize?: AuthorizeCheck | readonly AuthorizeCheck[];
|
|
132
143
|
/**
|
|
133
144
|
* Access log for every request (method, path, status, duration, correlation
|
|
134
145
|
* id). `true` enables the default single-line sink; pass an object to plug a
|
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.108.0" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
package/core/kernel/Kernel.ts
CHANGED
|
@@ -261,18 +261,32 @@ export class Kernel {
|
|
|
261
261
|
if (this.startupCleanupFailure !== undefined) return Promise.reject(this.startupCleanupFailure);
|
|
262
262
|
return Promise.resolve();
|
|
263
263
|
}
|
|
264
|
+
// Only an application that started announces its stop.
|
|
265
|
+
const wasStarted = this.state === "started";
|
|
264
266
|
this.state = "stopping";
|
|
265
|
-
this.shutdownPromise = this.stopCore(request);
|
|
267
|
+
this.shutdownPromise = this.stopCore(request, wasStarted);
|
|
266
268
|
return this.shutdownPromise;
|
|
267
269
|
}
|
|
268
270
|
|
|
269
|
-
private async stopCore(request: StopRequest): Promise<void> {
|
|
271
|
+
private async stopCore(request: StopRequest, wasStarted: boolean): Promise<void> {
|
|
270
272
|
// The whole graceful-stop sequence — lifetime notifications, the STOPPING
|
|
271
273
|
// event, hosted services/hooks and disposal — is bounded by one budget. A
|
|
272
274
|
// single hanging callback or handler must not wedge shutdown forever.
|
|
275
|
+
const announce = wasStarted && this.options.startupReport && this.options.schemaOnly !== true;
|
|
276
|
+
const logger = announce ? this.container.tryResolve(LOGGER) : undefined;
|
|
277
|
+
const startedAt = performance.now();
|
|
273
278
|
const work = this.runShutdownSequence(request);
|
|
274
279
|
try {
|
|
275
280
|
await awaitShutdown(work, this.options.shutdownTimeoutMs);
|
|
281
|
+
if (announce) {
|
|
282
|
+
const fields = {
|
|
283
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
284
|
+
exitCode: request.exitCode,
|
|
285
|
+
shutdownMs: Number((performance.now() - startedAt).toFixed(1)),
|
|
286
|
+
};
|
|
287
|
+
if (logger) logger.info("bazis stopped", fields);
|
|
288
|
+
else console.log(`[bazis] stopped ${JSON.stringify(fields)}`);
|
|
289
|
+
}
|
|
276
290
|
} finally {
|
|
277
291
|
this.state = "stopped";
|
|
278
292
|
}
|
|
@@ -441,6 +455,12 @@ export class Kernel {
|
|
|
441
455
|
console.error("[bazis] Unhandled error, shutting down:", redacted);
|
|
442
456
|
}
|
|
443
457
|
|
|
458
|
+
/** Where the servers listen: `{ listening: ["http://localhost:3000"] }`. */
|
|
459
|
+
private listening(): { listening?: readonly string[] } {
|
|
460
|
+
const urls = (this.container.tryResolve(StartupReport)?.listeners ?? []).map((listener) => listener.url);
|
|
461
|
+
return urls.length > 0 ? { listening: urls } : {};
|
|
462
|
+
}
|
|
463
|
+
|
|
444
464
|
private printStartupReport(): void {
|
|
445
465
|
if (this.options.onStarted !== undefined) {
|
|
446
466
|
try {
|
|
@@ -460,6 +480,7 @@ export class Kernel {
|
|
|
460
480
|
hostedServices: this.coordinator.startedHostedCount,
|
|
461
481
|
hooks: this.coordinator.hookCount,
|
|
462
482
|
startupMs: Number(this.startupMs.toFixed(1)),
|
|
483
|
+
...this.listening(),
|
|
463
484
|
};
|
|
464
485
|
if (logger) {
|
|
465
486
|
logger.info("bazis started", fields);
|
|
@@ -166,9 +166,11 @@ export class KernelBuilder {
|
|
|
166
166
|
const userRoot = resolveUserRoot(this.rootModule, environment);
|
|
167
167
|
const configs = collectModuleConfigs([userRoot]);
|
|
168
168
|
const configRegistry = new ConfigRegistry(configs, environment.name, configuration);
|
|
169
|
-
|
|
170
|
-
//
|
|
171
|
-
const
|
|
169
|
+
// The `bazis started` / `bazis stopped` lines are printed in every
|
|
170
|
+
// environment: in production they are the only sign that the process is up.
|
|
171
|
+
const startupReport = this.startupReport ?? true;
|
|
172
|
+
// The screen replaces the started line outside production.
|
|
173
|
+
const screen = this.startupScreen !== undefined && (this.startupReport ?? !environment.isProduction);
|
|
172
174
|
const report = new StartupReport(screen);
|
|
173
175
|
report.configuration(configRegistry.inspect());
|
|
174
176
|
this.builtReport = report;
|
|
@@ -98,7 +98,7 @@ const en = {
|
|
|
98
98
|
codegen: "codegen", typecheck: "typecheck", config: "config", connections: "connections", schema: "schema",
|
|
99
99
|
modules: "modules", services: "services", tests: "bun test", compile: "compile", files: "files", register: "register",
|
|
100
100
|
},
|
|
101
|
-
meta: { env: "env", watch: "watch", on: "on", off: "off", src: "src", pid: "pid", target: "target", out: "output", path: "path" },
|
|
101
|
+
meta: { env: "env", watch: "watch", on: "on", off: "off", src: "src", pid: "pid", target: "target", out: "output", path: "path", platform: "platform" },
|
|
102
102
|
common: {
|
|
103
103
|
of: "of", in: "in", and: "and", none: "none", ok: "ok", empty: "empty", plus: "plus", memory: "memory",
|
|
104
104
|
declaredIn: "declared in", code: "code", where: "where", try: "try", logs: "logs", inProcess: "in process",
|
|
@@ -260,7 +260,7 @@ const ru: Messages = {
|
|
|
260
260
|
codegen: "кодогенерация", typecheck: "типы", config: "конфигурация", connections: "подключения", schema: "схема",
|
|
261
261
|
modules: "модули", services: "сервисы", tests: "bun test", compile: "компиляция", files: "файлы", register: "подключение",
|
|
262
262
|
},
|
|
263
|
-
meta: { env: "среда", watch: "слежение", on: "вкл", off: "выкл", src: "вход", pid: "pid", target: "цель", out: "файл", path: "путь" },
|
|
263
|
+
meta: { env: "среда", watch: "слежение", on: "вкл", off: "выкл", src: "вход", pid: "pid", target: "цель", out: "файл", path: "путь", platform: "платформа" },
|
|
264
264
|
common: {
|
|
265
265
|
of: "из", in: "в", and: "и", none: "нет", ok: "в порядке", empty: "пусто", plus: "ещё", memory: "память",
|
|
266
266
|
declaredIn: "объявлено в", code: "код", where: "где", try: "попробуйте", logs: "журнал", inProcess: "в процессе",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.108.0",
|
|
4
4
|
"description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexander Alenin",
|