@tymber/common 0.0.1-alpha.0 → 0.1.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/LICENSE +22 -0
- package/README.md +46 -0
- package/dist/App.d.ts +17 -0
- package/dist/App.js +236 -0
- package/dist/Component.d.ts +16 -0
- package/dist/Component.js +116 -0
- package/dist/ConfigService.d.ts +31 -0
- package/dist/ConfigService.js +75 -0
- package/dist/Context.d.ts +41 -0
- package/dist/Context.js +10 -0
- package/dist/DB.d.ts +15 -0
- package/dist/DB.js +7 -0
- package/dist/Endpoint.d.ts +21 -0
- package/dist/Endpoint.js +87 -0
- package/dist/EventEmitter.d.ts +9 -0
- package/dist/EventEmitter.js +21 -0
- package/dist/Handler.d.ts +8 -0
- package/dist/Handler.js +8 -0
- package/dist/HttpContext.d.ts +26 -0
- package/dist/HttpContext.js +11 -0
- package/dist/I18nService.d.ts +18 -0
- package/dist/I18nService.js +72 -0
- package/dist/Middleware.d.ts +6 -0
- package/dist/Middleware.js +4 -0
- package/dist/Module.d.ts +47 -0
- package/dist/Module.js +12 -0
- package/dist/PubSubService.d.ts +20 -0
- package/dist/PubSubService.js +60 -0
- package/dist/Repository.d.ts +29 -0
- package/dist/Repository.js +110 -0
- package/dist/Router.d.ts +10 -0
- package/dist/Router.js +53 -0
- package/dist/TemplateService.d.ts +22 -0
- package/dist/TemplateService.js +66 -0
- package/dist/View.d.ts +17 -0
- package/dist/View.js +48 -0
- package/dist/ViewRenderer.d.ts +16 -0
- package/dist/ViewRenderer.js +59 -0
- package/dist/contrib/accept-language-parser.d.ts +9 -0
- package/dist/contrib/accept-language-parser.js +73 -0
- package/dist/contrib/cookie.d.ts +33 -0
- package/dist/contrib/cookie.js +207 -0
- package/dist/contrib/template.d.ts +1 -0
- package/dist/contrib/template.js +107 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +32 -0
- package/dist/utils/ajv.d.ts +2 -0
- package/dist/utils/ajv.js +10 -0
- package/dist/utils/camelToSnakeCase.d.ts +1 -0
- package/dist/utils/camelToSnakeCase.js +3 -0
- package/dist/utils/computeBaseUrl.d.ts +1 -0
- package/dist/utils/computeBaseUrl.js +37 -0
- package/dist/utils/computeContentType.d.ts +1 -0
- package/dist/utils/computeContentType.js +17 -0
- package/dist/utils/createDebug.d.ts +1 -0
- package/dist/utils/createDebug.js +4 -0
- package/dist/utils/createTestApp.d.ts +8 -0
- package/dist/utils/createTestApp.js +57 -0
- package/dist/utils/escapeValue.d.ts +1 -0
- package/dist/utils/escapeValue.js +3 -0
- package/dist/utils/fs.d.ts +6 -0
- package/dist/utils/fs.js +13 -0
- package/dist/utils/isAdmin.d.ts +2 -0
- package/dist/utils/isAdmin.js +4 -0
- package/dist/utils/isProduction.d.ts +1 -0
- package/dist/utils/isProduction.js +1 -0
- package/dist/utils/loadModules.d.ts +3 -0
- package/dist/utils/loadModules.js +83 -0
- package/dist/utils/randomId.d.ts +1 -0
- package/dist/utils/randomId.js +4 -0
- package/dist/utils/randomUUID.d.ts +1 -0
- package/dist/utils/randomUUID.js +4 -0
- package/dist/utils/runMigrations.d.ts +3 -0
- package/dist/utils/runMigrations.js +85 -0
- package/dist/utils/snakeToCamelCase.d.ts +1 -0
- package/dist/utils/snakeToCamelCase.js +3 -0
- package/dist/utils/sortBy.d.ts +1 -0
- package/dist/utils/sortBy.js +8 -0
- package/dist/utils/sql.d.ts +120 -0
- package/dist/utils/sql.js +433 -0
- package/dist/utils/toNodeHandler.d.ts +2 -0
- package/dist/utils/toNodeHandler.js +91 -0
- package/dist/utils/types.d.ts +3 -0
- package/dist/utils/types.js +1 -0
- package/dist/utils/waitFor.d.ts +1 -0
- package/dist/utils/waitFor.js +5 -0
- package/package.json +33 -2
- package/src/App.ts +319 -0
- package/src/Component.ts +166 -0
- package/src/ConfigService.ts +121 -0
- package/src/Context.ts +60 -0
- package/src/DB.ts +28 -0
- package/src/Endpoint.ts +118 -0
- package/src/EventEmitter.ts +32 -0
- package/src/Handler.ts +14 -0
- package/src/HttpContext.ts +35 -0
- package/src/I18nService.ts +96 -0
- package/src/Middleware.ts +10 -0
- package/src/Module.ts +60 -0
- package/src/PubSubService.ts +77 -0
- package/src/Repository.ts +158 -0
- package/src/Router.ts +77 -0
- package/src/TemplateService.ts +97 -0
- package/src/View.ts +60 -0
- package/src/ViewRenderer.ts +71 -0
- package/src/contrib/accept-language-parser.ts +94 -0
- package/src/contrib/cookie.ts +256 -0
- package/src/contrib/template.ts +134 -0
- package/src/index.ts +54 -0
- package/src/utils/ajv.ts +13 -0
- package/src/utils/camelToSnakeCase.ts +3 -0
- package/src/utils/computeBaseUrl.ts +46 -0
- package/src/utils/computeContentType.ts +17 -0
- package/src/utils/createDebug.ts +5 -0
- package/src/utils/createTestApp.ts +84 -0
- package/src/utils/escapeValue.ts +3 -0
- package/src/utils/fs.ts +15 -0
- package/src/utils/isAdmin.ts +5 -0
- package/src/utils/isProduction.ts +2 -0
- package/src/utils/loadModules.ts +105 -0
- package/src/utils/randomId.ts +5 -0
- package/src/utils/randomUUID.ts +5 -0
- package/src/utils/runMigrations.ts +122 -0
- package/src/utils/snakeToCamelCase.ts +3 -0
- package/src/utils/sortBy.ts +8 -0
- package/src/utils/sql.ts +553 -0
- package/src/utils/toNodeHandler.ts +121 -0
- package/src/utils/types.ts +1 -0
- package/src/utils/waitFor.ts +5 -0
package/src/App.ts
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { type HttpMethod, Router } from "./Router.js";
|
|
2
|
+
import {
|
|
3
|
+
type Module,
|
|
4
|
+
type ModuleDefinition,
|
|
5
|
+
ModuleDefinitions,
|
|
6
|
+
} from "./Module.js";
|
|
7
|
+
import { Middleware } from "./Middleware.js";
|
|
8
|
+
import { parseCookieHeader } from "./contrib/cookie.js";
|
|
9
|
+
import { type HttpContext, HttpRedirectCode } from "./HttpContext.js";
|
|
10
|
+
import { computeBaseUrl } from "./utils/computeBaseUrl.js";
|
|
11
|
+
import { ViewRenderer } from "./ViewRenderer.js";
|
|
12
|
+
import { computeContentType } from "./utils/computeContentType.js";
|
|
13
|
+
import { loadModules } from "./utils/loadModules.js";
|
|
14
|
+
import { BaseI18nService } from "./I18nService.js";
|
|
15
|
+
import { BaseTemplateService } from "./TemplateService.js";
|
|
16
|
+
import { runMigrations } from "./utils/runMigrations.js";
|
|
17
|
+
import { createDebug } from "./utils/createDebug.js";
|
|
18
|
+
import { DB } from "./DB.js";
|
|
19
|
+
import { isProduction } from "./utils/isProduction.js";
|
|
20
|
+
import { Component, ComponentFactory } from "./Component.js";
|
|
21
|
+
import { PubSubService } from "./PubSubService.js";
|
|
22
|
+
import { FS } from "./utils/fs.js";
|
|
23
|
+
import { EnvironmentBasedConfigService } from "./ConfigService.js";
|
|
24
|
+
import { snakeToCamelCase } from "./utils/snakeToCamelCase.js";
|
|
25
|
+
|
|
26
|
+
const debug = createDebug("App");
|
|
27
|
+
|
|
28
|
+
function createRouter(modules: ModuleDefinition[]) {
|
|
29
|
+
const router = new Router();
|
|
30
|
+
|
|
31
|
+
for (const module of modules) {
|
|
32
|
+
for (const { method, path, handler } of module.endpoints) {
|
|
33
|
+
router.registerRoute(method, path, handler);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const { method, path, handler } of module.views) {
|
|
37
|
+
router.registerRoute(method, path, handler);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const { method, path, handler } of module.adminEndpoints) {
|
|
41
|
+
router.registerRoute(method, path, handler);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const { method, path, handler } of module.adminViews) {
|
|
45
|
+
router.registerRoute(method, path, handler);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return router;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseQueryParams(url: URL) {
|
|
53
|
+
const query = Object.create(null);
|
|
54
|
+
|
|
55
|
+
url.searchParams.forEach((value, key) => {
|
|
56
|
+
query[snakeToCamelCase(key)] = value;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return query;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class App {
|
|
63
|
+
private readonly router: Router;
|
|
64
|
+
private readonly middlewares: Middleware[] = [];
|
|
65
|
+
|
|
66
|
+
private constructor(
|
|
67
|
+
modules: ModuleDefinition[],
|
|
68
|
+
private readonly assets: Map<string, string>,
|
|
69
|
+
private readonly viewRenderer: ViewRenderer,
|
|
70
|
+
) {
|
|
71
|
+
this.router = createRouter(modules);
|
|
72
|
+
for (const module of modules) {
|
|
73
|
+
this.middlewares.push(...module.middlewares);
|
|
74
|
+
}
|
|
75
|
+
this.fetch = this.fetch.bind(this);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static async create({
|
|
79
|
+
components,
|
|
80
|
+
modules,
|
|
81
|
+
}: {
|
|
82
|
+
components: Component[];
|
|
83
|
+
modules: Module[];
|
|
84
|
+
}) {
|
|
85
|
+
const db = components.find((c) => {
|
|
86
|
+
return c instanceof DB;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (!db) {
|
|
90
|
+
throw new Error("no DB component found");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
debug(
|
|
94
|
+
"starting app in %s mode",
|
|
95
|
+
isProduction ? "production" : "development",
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const componentFactory = new ComponentFactory();
|
|
99
|
+
|
|
100
|
+
let viewRenderer: ViewRenderer;
|
|
101
|
+
|
|
102
|
+
componentFactory.register(PubSubService);
|
|
103
|
+
componentFactory.register(EnvironmentBasedConfigService);
|
|
104
|
+
componentFactory.register(BaseI18nService);
|
|
105
|
+
componentFactory.register(BaseTemplateService);
|
|
106
|
+
componentFactory.register(ViewRenderer, (instance) => {
|
|
107
|
+
viewRenderer = instance;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
debug("loading modules");
|
|
111
|
+
const moduleDefinitions = await loadModules(componentFactory, modules);
|
|
112
|
+
const allComponents = componentFactory.build(
|
|
113
|
+
new ModuleDefinitions(moduleDefinitions),
|
|
114
|
+
...components,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
debug("running migrations");
|
|
118
|
+
await runMigrations(db, moduleDefinitions);
|
|
119
|
+
|
|
120
|
+
debug("initializing all components");
|
|
121
|
+
await Promise.all(allComponents.map((component) => component.init()));
|
|
122
|
+
|
|
123
|
+
const assets = new Map<string, string>();
|
|
124
|
+
|
|
125
|
+
for (const module of modules) {
|
|
126
|
+
if (!module.assetsDir) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
for (const filename of await FS.readDirRecursively(
|
|
132
|
+
FS.join(module.assetsDir, "static"),
|
|
133
|
+
)) {
|
|
134
|
+
assets.set(
|
|
135
|
+
"/static/" + filename,
|
|
136
|
+
FS.join(module.assetsDir, "static", filename),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
} catch (e) {}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const app = new App(moduleDefinitions, assets, viewRenderer!);
|
|
143
|
+
|
|
144
|
+
app.close = () => {
|
|
145
|
+
debug("closing all components");
|
|
146
|
+
return Promise.all(
|
|
147
|
+
components.map((component) => component.close()),
|
|
148
|
+
).then();
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return app;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
public async fetch(req: Request): Promise<Response> {
|
|
155
|
+
const url = new URL(req.url, "http://localhost");
|
|
156
|
+
|
|
157
|
+
const locale = this.viewRenderer.computeLocale(req);
|
|
158
|
+
|
|
159
|
+
const ctx = {
|
|
160
|
+
startedAt: new Date(),
|
|
161
|
+
method: req.method as HttpMethod,
|
|
162
|
+
path: url.pathname,
|
|
163
|
+
query: parseQueryParams(url),
|
|
164
|
+
headers: req.headers,
|
|
165
|
+
cookies: parseCookieHeader(req.headers.get("cookie") || ""),
|
|
166
|
+
abortSignal: req.signal,
|
|
167
|
+
locale,
|
|
168
|
+
responseHeaders: new Headers(),
|
|
169
|
+
|
|
170
|
+
render: async (view, data = {}) => {
|
|
171
|
+
try {
|
|
172
|
+
return this.viewRenderer.render(ctx, view, data);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
return this.renderHttp500Error(ctx, e as Error);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
redirect(
|
|
179
|
+
path: string,
|
|
180
|
+
code: HttpRedirectCode = HttpRedirectCode.HTTP_302_FOUND,
|
|
181
|
+
) {
|
|
182
|
+
const baseUrl = computeBaseUrl(ctx.headers);
|
|
183
|
+
// note: `Response.redirect()` does not allow to specify additional headers
|
|
184
|
+
return new Response(null, {
|
|
185
|
+
status: code,
|
|
186
|
+
headers: {
|
|
187
|
+
Location: `${baseUrl}${path}`,
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
} as HttpContext;
|
|
192
|
+
|
|
193
|
+
if (ctx.path.startsWith("/static")) {
|
|
194
|
+
if (!this.assets.has(ctx.path)) {
|
|
195
|
+
return this.renderHttp404Error(ctx);
|
|
196
|
+
}
|
|
197
|
+
const contentType = computeContentType(ctx.path);
|
|
198
|
+
const absolutePath = this.assets.get(ctx.path)!;
|
|
199
|
+
|
|
200
|
+
debug("serving %s with content type %s", ctx.path, contentType);
|
|
201
|
+
return new Response(FS.createReadStream(absolutePath), {
|
|
202
|
+
headers: {
|
|
203
|
+
"content-type": contentType,
|
|
204
|
+
"cache-control": isProduction
|
|
205
|
+
? "public, max-age=31536000, immutable"
|
|
206
|
+
: "no-cache",
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// make a copy in case a middleware is removed
|
|
212
|
+
const middlewares = this.middlewares.slice();
|
|
213
|
+
|
|
214
|
+
for (const middleware of middlewares) {
|
|
215
|
+
try {
|
|
216
|
+
const httpRes = await middleware.handle(ctx);
|
|
217
|
+
|
|
218
|
+
if (httpRes) {
|
|
219
|
+
ctx.responseHeaders.forEach((value, key) => {
|
|
220
|
+
httpRes.headers.set(key, value);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return httpRes;
|
|
224
|
+
}
|
|
225
|
+
} catch (e) {
|
|
226
|
+
return this.renderHttp500Error(ctx, e as Error);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const route = this.router.findRoute(req.method as HttpMethod, url.pathname);
|
|
231
|
+
|
|
232
|
+
if (!route) {
|
|
233
|
+
return this.renderHttp404Error(ctx);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
ctx.pathParams = route.pathParams;
|
|
237
|
+
|
|
238
|
+
const contentType = req.headers.get("content-type");
|
|
239
|
+
|
|
240
|
+
if (contentType) {
|
|
241
|
+
if (contentType !== "application/json") {
|
|
242
|
+
// TODO support other content types
|
|
243
|
+
return Response.json(
|
|
244
|
+
{
|
|
245
|
+
message: "unsupported media type",
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
status: 415,
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
ctx.payload = await req.json();
|
|
255
|
+
} catch {
|
|
256
|
+
return Response.json(
|
|
257
|
+
{
|
|
258
|
+
message: "invalid request body",
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
status: 400,
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const httpRes = await route.handler.doHandle(ctx);
|
|
269
|
+
|
|
270
|
+
ctx.responseHeaders.forEach((value, key) => {
|
|
271
|
+
httpRes.headers.set(key, value);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return httpRes;
|
|
275
|
+
} catch (e) {
|
|
276
|
+
return this.renderHttp500Error(ctx, e as Error);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private renderHttp404Error(ctx: HttpContext) {
|
|
281
|
+
const acceptHeader = ctx.headers.get("accept");
|
|
282
|
+
|
|
283
|
+
if (acceptHeader?.includes("text/html")) {
|
|
284
|
+
return this.viewRenderer.render(ctx, "404");
|
|
285
|
+
} else {
|
|
286
|
+
return Response.json(
|
|
287
|
+
{
|
|
288
|
+
message: "resource not found",
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
status: 404,
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private renderHttp500Error(ctx: HttpContext, error: Error) {
|
|
298
|
+
const acceptHeader = ctx.headers.get("accept");
|
|
299
|
+
|
|
300
|
+
if (acceptHeader?.includes("text/html")) {
|
|
301
|
+
return this.viewRenderer.render(ctx, "500", {
|
|
302
|
+
error,
|
|
303
|
+
});
|
|
304
|
+
} else {
|
|
305
|
+
return Response.json(
|
|
306
|
+
{
|
|
307
|
+
message: "an unexpected error occurred",
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
status: 500,
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
public close() {
|
|
317
|
+
return Promise.resolve();
|
|
318
|
+
}
|
|
319
|
+
}
|
package/src/Component.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { type Context, type Span } from "./Context.js";
|
|
2
|
+
|
|
3
|
+
export const INJECT = Symbol("dependencies");
|
|
4
|
+
|
|
5
|
+
export type Ctor<T> = new (...args: any[]) => T;
|
|
6
|
+
type AbstractCtor<T> = abstract new (...args: any[]) => T;
|
|
7
|
+
|
|
8
|
+
type Dependency = Ctor<Component> | AbstractCtor<Component>;
|
|
9
|
+
|
|
10
|
+
export abstract class Component {
|
|
11
|
+
private __isComponent!: void; // nominal typing
|
|
12
|
+
|
|
13
|
+
static [INJECT]: Dependency[] = [];
|
|
14
|
+
|
|
15
|
+
init(): void | Promise<void> {}
|
|
16
|
+
close(): void | Promise<void> {}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Node {
|
|
20
|
+
ctor: Ctor<Component>;
|
|
21
|
+
dependencies: Ctor<Component>[];
|
|
22
|
+
level: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isSubclass(subclass: any, superclass: any): boolean {
|
|
26
|
+
return superclass.prototype.isPrototypeOf(subclass.prototype);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function customConstructor<T extends { new (...args: any[]): any }>(
|
|
30
|
+
ctor: T,
|
|
31
|
+
onCreation: (instance: any) => void,
|
|
32
|
+
): T {
|
|
33
|
+
return class extends ctor {
|
|
34
|
+
constructor(...args: any[]) {
|
|
35
|
+
super(...args);
|
|
36
|
+
onCreation(this);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class ComponentFactory {
|
|
42
|
+
private readonly componentTree: Node[] = [];
|
|
43
|
+
|
|
44
|
+
public register<T extends Component>(
|
|
45
|
+
ctor: Ctor<T>,
|
|
46
|
+
onCreation?: (instance: T) => void,
|
|
47
|
+
) {
|
|
48
|
+
if (onCreation) {
|
|
49
|
+
ctor = customConstructor(ctor, onCreation);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let level = 0;
|
|
53
|
+
|
|
54
|
+
// find place in the tree
|
|
55
|
+
for (const node of this.componentTree) {
|
|
56
|
+
for (const dependency of node.dependencies) {
|
|
57
|
+
if (ctor === dependency || isSubclass(ctor, dependency)) {
|
|
58
|
+
level = Math.max(level, node.level + 1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// move dependencies at the bottom
|
|
64
|
+
// @ts-expect-error
|
|
65
|
+
const dependencies = (ctor[INJECT] || []) as Ctor<Component>[];
|
|
66
|
+
for (const node of this.componentTree) {
|
|
67
|
+
for (const dependency of dependencies) {
|
|
68
|
+
if (node.ctor === dependency || isSubclass(node.ctor, dependency)) {
|
|
69
|
+
node.level = Math.max(node.level, level + 1);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.componentTree.push({
|
|
75
|
+
ctor,
|
|
76
|
+
dependencies,
|
|
77
|
+
level,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public build(...baseComponents: Component[]) {
|
|
82
|
+
const components: Component[] = [...baseComponents];
|
|
83
|
+
|
|
84
|
+
const proxyHandler: ProxyHandler<any> = {
|
|
85
|
+
get(target, prop) {
|
|
86
|
+
const method = target[prop];
|
|
87
|
+
|
|
88
|
+
if (typeof method !== "function") {
|
|
89
|
+
return method;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return function (...args: any[]) {
|
|
93
|
+
const isTracingEnabled = args.length > 0 && args[0].tracing?.enabled;
|
|
94
|
+
if (!isTracingEnabled) {
|
|
95
|
+
return method.apply(target, args);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ctx = args[0] as Context;
|
|
99
|
+
|
|
100
|
+
const span: Span = {
|
|
101
|
+
component: target.constructor.name,
|
|
102
|
+
method: prop as string,
|
|
103
|
+
startedAt: Date.now(),
|
|
104
|
+
duration: -1,
|
|
105
|
+
isSuccess: false,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
ctx.tracing?.spans.push(span);
|
|
109
|
+
|
|
110
|
+
function completeSpan() {
|
|
111
|
+
span.duration = Date.now() - span.startedAt;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const result = method.apply(target, args);
|
|
116
|
+
|
|
117
|
+
if (isPromise(result)) {
|
|
118
|
+
return result
|
|
119
|
+
.then((output: any) => {
|
|
120
|
+
span.isSuccess = true;
|
|
121
|
+
return output;
|
|
122
|
+
})
|
|
123
|
+
.finally(() => completeSpan());
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
span.isSuccess = true;
|
|
127
|
+
return result;
|
|
128
|
+
} finally {
|
|
129
|
+
completeSpan();
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
this.componentTree.sort((a, b) => b.level - a.level);
|
|
136
|
+
|
|
137
|
+
// TODO do not instantiate components that are not used in the tree
|
|
138
|
+
for (const node of this.componentTree) {
|
|
139
|
+
const deps = node.dependencies.map((ctor) => {
|
|
140
|
+
const availableDeps = components.filter(
|
|
141
|
+
(component) => component instanceof ctor,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (availableDeps.length === 0) {
|
|
145
|
+
throw `unresolved dependency ${ctor.name} for ${node.ctor.name}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return availableDeps[availableDeps.length - 1];
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const component = new node.ctor(...deps);
|
|
152
|
+
components.push(new Proxy(component, proxyHandler));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return components;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isPromise(obj: any) {
|
|
160
|
+
return (
|
|
161
|
+
!!obj &&
|
|
162
|
+
typeof obj === "object" &&
|
|
163
|
+
typeof obj.then === "function" &&
|
|
164
|
+
typeof obj.catch === "function"
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { hash } from "node:crypto";
|
|
2
|
+
import { Component } from "./Component.js";
|
|
3
|
+
import { type Context, emptyContext } from "./Context.js";
|
|
4
|
+
|
|
5
|
+
type ConfigType = "string" | "string[]" | "number" | "boolean";
|
|
6
|
+
|
|
7
|
+
interface ConfigDefinition {
|
|
8
|
+
key: string;
|
|
9
|
+
type: ConfigType;
|
|
10
|
+
defaultValue?: any;
|
|
11
|
+
shouldObfuscate?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ConfigDefinitionWithValue extends ConfigDefinition {
|
|
15
|
+
value: any;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type ConfigValues = Record<string, any>;
|
|
19
|
+
|
|
20
|
+
interface ConfigHandler {
|
|
21
|
+
configDefinitions: ConfigDefinition[];
|
|
22
|
+
handler: (config: ConfigValues) => void | Promise<void>;
|
|
23
|
+
previousConfigHash: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export abstract class ConfigService extends Component {
|
|
27
|
+
protected handlers: ConfigHandler[] = [];
|
|
28
|
+
|
|
29
|
+
public subscribe(
|
|
30
|
+
configDefinitions: ConfigDefinition[],
|
|
31
|
+
handler: (config: ConfigValues) => void | Promise<void>,
|
|
32
|
+
) {
|
|
33
|
+
this.handlers.push({
|
|
34
|
+
configDefinitions,
|
|
35
|
+
handler,
|
|
36
|
+
previousConfigHash: "",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
override init() {
|
|
41
|
+
return this.notifyConsumers(emptyContext());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
protected async notifyConsumers(ctx: Context) {
|
|
45
|
+
const values = await this.getCurrentValues(ctx);
|
|
46
|
+
for (const elem of this.handlers) {
|
|
47
|
+
const config: ConfigValues = {};
|
|
48
|
+
|
|
49
|
+
for (const { key, defaultValue } of elem.configDefinitions) {
|
|
50
|
+
config[key] = values[key] || defaultValue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const configHash = hash("sha256", JSON.stringify(config));
|
|
54
|
+
|
|
55
|
+
if (configHash !== elem.previousConfigHash) {
|
|
56
|
+
elem.previousConfigHash = configHash;
|
|
57
|
+
await elem.handler(config);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public getDefinitions() {
|
|
63
|
+
return this.handlers.flatMap((elem) => elem.configDefinitions);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public async getCurrentConfig(ctx: Context) {
|
|
67
|
+
const values = await this.getCurrentValues(ctx);
|
|
68
|
+
const config: ConfigDefinitionWithValue[] = [];
|
|
69
|
+
|
|
70
|
+
for (const handler of this.handlers) {
|
|
71
|
+
for (const {
|
|
72
|
+
key,
|
|
73
|
+
type,
|
|
74
|
+
defaultValue,
|
|
75
|
+
shouldObfuscate,
|
|
76
|
+
} of handler.configDefinitions) {
|
|
77
|
+
config.push({
|
|
78
|
+
key,
|
|
79
|
+
type,
|
|
80
|
+
defaultValue,
|
|
81
|
+
value: values[key] ?? defaultValue,
|
|
82
|
+
shouldObfuscate,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return config;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
protected abstract getCurrentValues(ctx: Context): Promise<ConfigValues>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatValue(type: ConfigType, value: string) {
|
|
94
|
+
switch (type) {
|
|
95
|
+
case "string":
|
|
96
|
+
return value;
|
|
97
|
+
case "string[]":
|
|
98
|
+
return value.split(",");
|
|
99
|
+
case "number":
|
|
100
|
+
return parseInt(value, 10);
|
|
101
|
+
case "boolean":
|
|
102
|
+
return value === "1";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class EnvironmentBasedConfigService extends ConfigService {
|
|
107
|
+
override getCurrentValues() {
|
|
108
|
+
const availableKeys = Object.keys(process.env);
|
|
109
|
+
const values: Record<string, any> = {};
|
|
110
|
+
|
|
111
|
+
for (const handler of this.handlers) {
|
|
112
|
+
for (const { key, type } of handler.configDefinitions) {
|
|
113
|
+
if (availableKeys.includes(key)) {
|
|
114
|
+
values[key] = formatValue(type, process.env[key] ?? "");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return Promise.resolve(values);
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/Context.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type Brand } from "./utils/types.js";
|
|
2
|
+
|
|
3
|
+
export type InternalUserId = Brand<bigint, "InternalUserId">;
|
|
4
|
+
export type UserId = Brand<string, "UserId">;
|
|
5
|
+
export type InternalGroupId = Brand<bigint, "InternalGroupId">;
|
|
6
|
+
export type GroupId = Brand<string, "GroupId">;
|
|
7
|
+
export type Role = Brand<number, "Role">;
|
|
8
|
+
export type AdminUserId = Brand<number, "AdminUserId">;
|
|
9
|
+
|
|
10
|
+
export interface ConnectedUser {
|
|
11
|
+
internalId: InternalUserId;
|
|
12
|
+
id: UserId;
|
|
13
|
+
|
|
14
|
+
firstName: string;
|
|
15
|
+
lastName: string;
|
|
16
|
+
email: string;
|
|
17
|
+
|
|
18
|
+
groups: Array<{
|
|
19
|
+
internalId: InternalGroupId;
|
|
20
|
+
id: GroupId;
|
|
21
|
+
label: string;
|
|
22
|
+
role: Role;
|
|
23
|
+
}>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Admin {
|
|
27
|
+
id: AdminUserId;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Span {
|
|
31
|
+
component: string;
|
|
32
|
+
method: string;
|
|
33
|
+
startedAt: number;
|
|
34
|
+
duration: number;
|
|
35
|
+
isSuccess: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Context {
|
|
39
|
+
startedAt: Date;
|
|
40
|
+
|
|
41
|
+
tx?: any;
|
|
42
|
+
|
|
43
|
+
user?: ConnectedUser;
|
|
44
|
+
admin?: Admin;
|
|
45
|
+
|
|
46
|
+
tracing: {
|
|
47
|
+
enabled: boolean;
|
|
48
|
+
spans: Array<Span>;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function emptyContext(): Context {
|
|
53
|
+
return {
|
|
54
|
+
startedAt: new Date(),
|
|
55
|
+
tracing: {
|
|
56
|
+
enabled: false,
|
|
57
|
+
spans: [],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
package/src/DB.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Component } from "./Component.js";
|
|
2
|
+
import { type Context } from "./Context.js";
|
|
3
|
+
import { Statement } from "./utils/sql.js";
|
|
4
|
+
|
|
5
|
+
export abstract class DB extends Component {
|
|
6
|
+
public abstract readonly name: string;
|
|
7
|
+
|
|
8
|
+
abstract query<T extends Record<string, any>>(
|
|
9
|
+
ctx: Context,
|
|
10
|
+
query: Statement,
|
|
11
|
+
): Promise<T[]>;
|
|
12
|
+
|
|
13
|
+
abstract run(
|
|
14
|
+
ctx: Context,
|
|
15
|
+
query: Statement,
|
|
16
|
+
): Promise<{ affectedRows: number }>;
|
|
17
|
+
|
|
18
|
+
abstract exec(ctx: Context, query: Statement): Promise<void>;
|
|
19
|
+
|
|
20
|
+
abstract startTransaction(
|
|
21
|
+
ctx: Context,
|
|
22
|
+
fn: () => Promise<void>,
|
|
23
|
+
): void | Promise<void>;
|
|
24
|
+
|
|
25
|
+
abstract createMigrationsTable(ctx: Context): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class DuplicateKeyError extends Error {}
|