@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/Endpoint.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { type HttpContext } from "./HttpContext.js";
|
|
2
|
+
import { type ErrorObject, type ValidateFunction } from "ajv";
|
|
3
|
+
import { Handler } from "./Handler.js";
|
|
4
|
+
import { AJV_INSTANCE } from "./utils/ajv.js";
|
|
5
|
+
|
|
6
|
+
function formatErrors(
|
|
7
|
+
errors: ErrorObject<string, Record<string, any>, unknown>[],
|
|
8
|
+
) {
|
|
9
|
+
return errors.map((error) => ({
|
|
10
|
+
keyword: error.keyword,
|
|
11
|
+
message: error.message,
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
abstract class BaseEndpoint extends Handler {
|
|
16
|
+
protected pathParamsSchema?: unknown; // JSONSchemaType<Params>;
|
|
17
|
+
protected querySchema?: unknown; // JSONSchemaType<Query>;
|
|
18
|
+
protected payloadSchema?: unknown; // JSONSchemaType<Payload>;
|
|
19
|
+
|
|
20
|
+
private validatePathParams?: ValidateFunction;
|
|
21
|
+
private validateQuery?: ValidateFunction;
|
|
22
|
+
private validatePayload?: ValidateFunction;
|
|
23
|
+
|
|
24
|
+
override doHandle(ctx: HttpContext): Response | Promise<Response> {
|
|
25
|
+
const { pathParams, query, payload } = ctx;
|
|
26
|
+
|
|
27
|
+
if (this.pathParamsSchema && !this.validatePathParams) {
|
|
28
|
+
this.validatePathParams = AJV_INSTANCE.compile(this.pathParamsSchema);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (this.validatePathParams && !this.validatePathParams(pathParams)) {
|
|
32
|
+
return this.badRequest("invalid path params", {
|
|
33
|
+
errors: formatErrors(this.validatePathParams.errors!),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (this.querySchema && !this.validateQuery) {
|
|
38
|
+
this.validateQuery = AJV_INSTANCE.compile(this.querySchema);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (this.validateQuery && !this.validateQuery(query)) {
|
|
42
|
+
return this.badRequest("invalid query params", {
|
|
43
|
+
errors: formatErrors(this.validateQuery.errors!),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (this.payloadSchema && !this.validatePayload) {
|
|
48
|
+
this.validatePayload = AJV_INSTANCE.compile(this.payloadSchema);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (this.validatePayload && !this.validatePayload(payload)) {
|
|
52
|
+
return this.badRequest("invalid payload", {
|
|
53
|
+
errors: formatErrors(this.validatePayload.errors!),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!this.hasPermission(ctx)) {
|
|
58
|
+
return Response.json(
|
|
59
|
+
{
|
|
60
|
+
message: "forbidden",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
status: 403,
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return this.handle(ctx);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
protected badRequest(message: string, details?: any) {
|
|
72
|
+
return Response.json(
|
|
73
|
+
{
|
|
74
|
+
message,
|
|
75
|
+
...details,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
status: 400,
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export abstract class Endpoint extends BaseEndpoint {
|
|
85
|
+
private _endpointBrand!: void; // nominal typing
|
|
86
|
+
|
|
87
|
+
override doHandle(ctx: HttpContext) {
|
|
88
|
+
if (!this.allowAnonymous && !ctx.user) {
|
|
89
|
+
return Response.json(
|
|
90
|
+
{
|
|
91
|
+
message: "unauthorized",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
status: 401,
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return super.doHandle(ctx);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export abstract class AdminEndpoint extends BaseEndpoint {
|
|
103
|
+
private _adminEndpointBrand!: void; // nominal typing
|
|
104
|
+
|
|
105
|
+
override doHandle(ctx: HttpContext) {
|
|
106
|
+
if (!this.allowAnonymous && !ctx.admin) {
|
|
107
|
+
return Response.json(
|
|
108
|
+
{
|
|
109
|
+
message: "unauthorized",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
status: 401,
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return super.doHandle(ctx);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createDebug } from "./utils/createDebug.js";
|
|
2
|
+
|
|
3
|
+
const debug = createDebug("EventEmitter");
|
|
4
|
+
|
|
5
|
+
type EventMap = Record<string, any>;
|
|
6
|
+
type EventKey<T extends EventMap> = string & keyof T;
|
|
7
|
+
type EventCallback<T> = (payload: T) => void;
|
|
8
|
+
|
|
9
|
+
export class EventEmitter<Events extends EventMap> {
|
|
10
|
+
private listeners = new Map<
|
|
11
|
+
keyof Events,
|
|
12
|
+
Array<EventCallback<Events[keyof Events]>>
|
|
13
|
+
>();
|
|
14
|
+
|
|
15
|
+
on<K extends EventKey<Events>>(event: K, callback: EventCallback<Events[K]>) {
|
|
16
|
+
debug("subscribing to event %s", event);
|
|
17
|
+
if (!this.listeners.has(event)) {
|
|
18
|
+
this.listeners.set(event, []);
|
|
19
|
+
}
|
|
20
|
+
this.listeners
|
|
21
|
+
.get(event)!
|
|
22
|
+
.push(callback as EventCallback<Events[keyof Events]>);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
emit<K extends EventKey<Events>>(event: K, payload: Events[K]) {
|
|
26
|
+
debug("emitting event %s", event);
|
|
27
|
+
const callbacks = this.listeners.get(event);
|
|
28
|
+
if (callbacks) {
|
|
29
|
+
callbacks.forEach((callback) => callback(payload));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/Handler.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Component } from "./Component.js";
|
|
2
|
+
import { type HttpContext } from "./HttpContext.js";
|
|
3
|
+
|
|
4
|
+
export abstract class Handler extends Component {
|
|
5
|
+
protected allowAnonymous = false;
|
|
6
|
+
|
|
7
|
+
protected hasPermission(ctx: HttpContext) {
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public abstract doHandle(ctx: HttpContext): Response | Promise<Response>;
|
|
12
|
+
|
|
13
|
+
protected abstract handle(ctx: HttpContext): Response | Promise<Response>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Context } from "./Context.js";
|
|
2
|
+
import { type HttpMethod } from "./Router.js";
|
|
3
|
+
import { type Locale } from "./contrib/accept-language-parser.js";
|
|
4
|
+
|
|
5
|
+
export interface HttpContext<Payload = any, PathParams = any, QueryParams = any>
|
|
6
|
+
extends Context {
|
|
7
|
+
method: HttpMethod;
|
|
8
|
+
payload: Payload;
|
|
9
|
+
path: string;
|
|
10
|
+
pathParams: PathParams;
|
|
11
|
+
query: QueryParams;
|
|
12
|
+
headers: Headers;
|
|
13
|
+
cookies: Record<string, string>;
|
|
14
|
+
abortSignal: AbortSignal;
|
|
15
|
+
|
|
16
|
+
locale: Locale;
|
|
17
|
+
responseHeaders: Headers;
|
|
18
|
+
sessionId?: string;
|
|
19
|
+
adminSessionId?: string;
|
|
20
|
+
|
|
21
|
+
render: (
|
|
22
|
+
view: string | string[],
|
|
23
|
+
data?: Record<string, any>,
|
|
24
|
+
) => Promise<Response>;
|
|
25
|
+
|
|
26
|
+
redirect(path: string, type?: HttpRedirectCode): Response;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export enum HttpRedirectCode {
|
|
30
|
+
HTTP_301_MOVED_PERMANENTLY = 301,
|
|
31
|
+
HTTP_302_FOUND = 302,
|
|
32
|
+
HTTP_303_SEE_OTHER = 303,
|
|
33
|
+
HTTP_307_TEMPORARY_REDIRECT = 307,
|
|
34
|
+
HTTP_308_PERMANENT_REDIRECT = 308,
|
|
35
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type Locale } from "./contrib/accept-language-parser.js";
|
|
2
|
+
import { ModuleDefinitions } from "./Module.js";
|
|
3
|
+
import { compileTemplate } from "./contrib/template.js";
|
|
4
|
+
import { Component, INJECT } from "./Component.js";
|
|
5
|
+
import type { Context } from "./Context.js";
|
|
6
|
+
import { FS } from "./utils/fs.js";
|
|
7
|
+
|
|
8
|
+
function flatten(obj: any) {
|
|
9
|
+
const output: Record<string, string> = {};
|
|
10
|
+
|
|
11
|
+
function flattenRecursively(curr: any, parentKey: string) {
|
|
12
|
+
if (typeof curr === "object" && curr !== null && !Array.isArray(curr)) {
|
|
13
|
+
for (const key in curr) {
|
|
14
|
+
flattenRecursively(curr[key], parentKey ? `${parentKey}.${key}` : key);
|
|
15
|
+
}
|
|
16
|
+
} else {
|
|
17
|
+
output[parentKey] = curr;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
flattenRecursively(obj, "");
|
|
22
|
+
return output;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export abstract class I18nService extends Component {
|
|
26
|
+
public abstract availableLocales(): Locale[];
|
|
27
|
+
public abstract translate(
|
|
28
|
+
ctx: Context,
|
|
29
|
+
locale: Locale,
|
|
30
|
+
key: string,
|
|
31
|
+
...args: any[]
|
|
32
|
+
): string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class BaseI18nService extends I18nService {
|
|
36
|
+
static [INJECT] = [ModuleDefinitions];
|
|
37
|
+
|
|
38
|
+
private readonly translations = new Map<string, Map<string, string>>();
|
|
39
|
+
|
|
40
|
+
constructor(private readonly modules: ModuleDefinitions) {
|
|
41
|
+
super();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override init() {
|
|
45
|
+
return this.loadTranslations();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async loadTranslations() {
|
|
49
|
+
try {
|
|
50
|
+
for (const module of this.modules.modules) {
|
|
51
|
+
if (!module.assetsDir) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
for (const filename of await FS.readDirRecursively(
|
|
55
|
+
FS.join(module.assetsDir, "i18n"),
|
|
56
|
+
)) {
|
|
57
|
+
if (!filename.endsWith(".json")) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const content = await FS.readFile(
|
|
61
|
+
FS.join(module.assetsDir, "i18n", filename),
|
|
62
|
+
);
|
|
63
|
+
const locale = filename.slice(0, -5);
|
|
64
|
+
const values = JSON.parse(content);
|
|
65
|
+
|
|
66
|
+
let localizedTranslations = this.translations.get(locale);
|
|
67
|
+
if (!localizedTranslations) {
|
|
68
|
+
this.translations.set(locale, (localizedTranslations = new Map()));
|
|
69
|
+
}
|
|
70
|
+
const flatTranslations = flatten(values);
|
|
71
|
+
for (const [key, value] of Object.entries(flatTranslations)) {
|
|
72
|
+
localizedTranslations.set(key, value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch (e) {}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
override availableLocales() {
|
|
80
|
+
return Array.from(this.translations.keys()) as Locale[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
override translate(
|
|
84
|
+
_ctx: Context,
|
|
85
|
+
locale: Locale,
|
|
86
|
+
key: string,
|
|
87
|
+
...args: any[]
|
|
88
|
+
) {
|
|
89
|
+
const value = this.translations.get(locale)?.get(key);
|
|
90
|
+
if (value && value.includes("<%")) {
|
|
91
|
+
return compileTemplate(value)(args);
|
|
92
|
+
} else {
|
|
93
|
+
return value || "";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { HttpContext } from "./HttpContext.js";
|
|
2
|
+
import { Component } from "./Component.js";
|
|
3
|
+
|
|
4
|
+
export abstract class Middleware extends Component {
|
|
5
|
+
private _middlewareBrand!: void; // nominal typing
|
|
6
|
+
|
|
7
|
+
public abstract handle(
|
|
8
|
+
ctx: HttpContext,
|
|
9
|
+
): void | Response | Promise<void | Response>;
|
|
10
|
+
}
|
package/src/Module.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Component, type Ctor } from "./Component.js";
|
|
2
|
+
import type { HttpMethod } from "./Router.js";
|
|
3
|
+
import { AdminEndpoint, Endpoint } from "./Endpoint.js";
|
|
4
|
+
import { AdminView, View } from "./View.js";
|
|
5
|
+
import { Middleware } from "./Middleware.js";
|
|
6
|
+
import { type Handler } from "./Handler.js";
|
|
7
|
+
|
|
8
|
+
export interface AdminSidebarItem {
|
|
9
|
+
label: string;
|
|
10
|
+
icon: string;
|
|
11
|
+
path: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Module {
|
|
15
|
+
name: string;
|
|
16
|
+
version: string;
|
|
17
|
+
assetsDir?: string;
|
|
18
|
+
adminSidebarItems?: AdminSidebarItem[];
|
|
19
|
+
|
|
20
|
+
init(app: AppInit): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AppInit {
|
|
24
|
+
component<T extends Component>(ctor: Ctor<T>): void;
|
|
25
|
+
endpoint(method: HttpMethod, path: string, ctor: Ctor<Endpoint>): void;
|
|
26
|
+
view(path: string, ctor: Ctor<View>): void;
|
|
27
|
+
adminEndpoint(
|
|
28
|
+
method: HttpMethod,
|
|
29
|
+
path: string,
|
|
30
|
+
ctor: Ctor<AdminEndpoint>,
|
|
31
|
+
): void;
|
|
32
|
+
adminView(path: string, ctor: Ctor<AdminView>): void;
|
|
33
|
+
middleware(ctor: Ctor<Middleware>): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Route {
|
|
37
|
+
method: HttpMethod;
|
|
38
|
+
path: string;
|
|
39
|
+
handlerName: string;
|
|
40
|
+
handler: Handler;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ModuleDefinition {
|
|
44
|
+
name: string;
|
|
45
|
+
version: string;
|
|
46
|
+
assetsDir?: string;
|
|
47
|
+
adminSidebarItems?: AdminSidebarItem[];
|
|
48
|
+
|
|
49
|
+
endpoints: Route[];
|
|
50
|
+
views: Route[];
|
|
51
|
+
adminEndpoints: Route[];
|
|
52
|
+
adminViews: Route[];
|
|
53
|
+
middlewares: Middleware[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class ModuleDefinitions extends Component {
|
|
57
|
+
constructor(public readonly modules: ModuleDefinition[]) {
|
|
58
|
+
super();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Component } from "./Component.js";
|
|
2
|
+
import { createDebug } from "./utils/createDebug.js";
|
|
3
|
+
import type { Context } from "./Context.js";
|
|
4
|
+
import { EventEmitter } from "./EventEmitter.js";
|
|
5
|
+
import cluster from "node:cluster";
|
|
6
|
+
import { randomId } from "./utils/randomId.js";
|
|
7
|
+
|
|
8
|
+
const debug = createDebug("PubSubService");
|
|
9
|
+
|
|
10
|
+
interface Message {
|
|
11
|
+
from: string;
|
|
12
|
+
type: string;
|
|
13
|
+
payload?: any;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class PubSubService extends Component {
|
|
17
|
+
protected readonly id = randomId();
|
|
18
|
+
private eventEmitter = new EventEmitter();
|
|
19
|
+
|
|
20
|
+
public publish(_ctx: Context, _type: string, _payload?: any) {
|
|
21
|
+
// noop
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public subscribe<T>(type: string, handler: (payload: T) => void): void {
|
|
25
|
+
this.eventEmitter.on(type, handler);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
protected onMessage(message: Message) {
|
|
29
|
+
if (message.from === this.id) {
|
|
30
|
+
debug("ignore message from self");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
debug("received message %s", message.type);
|
|
34
|
+
this.eventEmitter.emit(message.type, message.payload);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ignoreError() {}
|
|
39
|
+
|
|
40
|
+
export class NodeClusterPubSubService extends PubSubService {
|
|
41
|
+
constructor() {
|
|
42
|
+
super();
|
|
43
|
+
if (!cluster.isWorker) {
|
|
44
|
+
throw "not worker";
|
|
45
|
+
}
|
|
46
|
+
process.on("message", (message: Message) => this.onMessage(message));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
override publish(_ctx: Context, type: string, payload?: any) {
|
|
50
|
+
if (typeof process.send !== "function") {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
debug("sending message %s", type);
|
|
54
|
+
const message = {
|
|
55
|
+
from: this.id,
|
|
56
|
+
type,
|
|
57
|
+
payload,
|
|
58
|
+
} as Message;
|
|
59
|
+
process.send(message, undefined, {}, ignoreError);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function initPrimary() {
|
|
64
|
+
if (!cluster.isPrimary) {
|
|
65
|
+
throw "not primary";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
cluster.on("message", (worker, message) => {
|
|
69
|
+
const emitterId = String(worker.id);
|
|
70
|
+
debug("forwarding message %s to other workers", message.type);
|
|
71
|
+
for (const workerId in cluster.workers) {
|
|
72
|
+
if (workerId !== emitterId) {
|
|
73
|
+
cluster.workers[workerId]?.send(message, ignoreError);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { camelToSnakeCase } from "./utils/camelToSnakeCase.js";
|
|
2
|
+
import { snakeToCamelCase } from "./utils/snakeToCamelCase.js";
|
|
3
|
+
import { Component, INJECT } from "./Component.js";
|
|
4
|
+
import { DB } from "./DB.js";
|
|
5
|
+
import type { Context } from "./Context.js";
|
|
6
|
+
import { sql, Statement } from "./utils/sql.js";
|
|
7
|
+
|
|
8
|
+
export class EntityNotFoundError extends Error {}
|
|
9
|
+
|
|
10
|
+
export abstract class Repository<
|
|
11
|
+
ID,
|
|
12
|
+
T extends Record<string, any>,
|
|
13
|
+
> extends Component {
|
|
14
|
+
static [INJECT] = [DB];
|
|
15
|
+
|
|
16
|
+
protected abstract tableName: string;
|
|
17
|
+
protected idField: string | string[] = "id";
|
|
18
|
+
protected dateFields: string[] = [];
|
|
19
|
+
|
|
20
|
+
constructor(protected readonly db: DB) {
|
|
21
|
+
super();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public findById(ctx: Context, id: ID) {
|
|
25
|
+
const query = sql.select().from(this.tableName).where(this.idClause(id));
|
|
26
|
+
|
|
27
|
+
return this.one(ctx, query);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async deleteById(ctx: Context, id: ID) {
|
|
31
|
+
const query = sql.deleteFrom(this.tableName).where(this.idClause(id));
|
|
32
|
+
|
|
33
|
+
const res = await this.db.run(ctx, query);
|
|
34
|
+
|
|
35
|
+
if (res.affectedRows !== 1) {
|
|
36
|
+
throw new EntityNotFoundError();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private idClause(id: ID) {
|
|
41
|
+
if (Array.isArray(this.idField)) {
|
|
42
|
+
return this.idField.reduce((acc, k) => {
|
|
43
|
+
// @ts-expect-error
|
|
44
|
+
acc[camelToSnakeCase(k)] = id[k];
|
|
45
|
+
return acc;
|
|
46
|
+
}, {});
|
|
47
|
+
} else {
|
|
48
|
+
return {
|
|
49
|
+
[camelToSnakeCase(this.idField)]: id,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public async save(ctx: Context, entity: Partial<T>) {
|
|
55
|
+
const idFields = Array.isArray(this.idField)
|
|
56
|
+
? this.idField
|
|
57
|
+
: [this.idField];
|
|
58
|
+
const isIDSpecified = idFields.every(
|
|
59
|
+
(idField) => entity[idField] !== undefined,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (isIDSpecified) {
|
|
63
|
+
const idClause: Record<string, any> = {};
|
|
64
|
+
idFields.forEach((idField) => {
|
|
65
|
+
idClause[camelToSnakeCase(idField)] = entity[idField];
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
this.onBeforeUpdate(ctx, entity);
|
|
69
|
+
|
|
70
|
+
const { affectedRows } = await this.db.run(
|
|
71
|
+
ctx,
|
|
72
|
+
sql.update(this.tableName).set(this.toRow(entity)).where(idClause),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (affectedRows > 1) {
|
|
76
|
+
throw "unexpected number of updated rows";
|
|
77
|
+
} else if (affectedRows === 1) {
|
|
78
|
+
return entity as T;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.onBeforeInsert(ctx, entity);
|
|
83
|
+
|
|
84
|
+
const rows = await this.db.query(
|
|
85
|
+
ctx,
|
|
86
|
+
sql
|
|
87
|
+
.insert()
|
|
88
|
+
.into(this.tableName)
|
|
89
|
+
.values([this.toRow(entity)])
|
|
90
|
+
.returning(idFields.map((idField) => camelToSnakeCase(idField))),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (rows.length !== 1) {
|
|
94
|
+
throw "unexpected number of inserted rows";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
Object.assign(entity, this.toEntity(rows[0]));
|
|
98
|
+
|
|
99
|
+
return entity as T;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public startTransaction(ctx: Context, fn: () => Promise<void>) {
|
|
103
|
+
return this.db.startTransaction(ctx, fn);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
protected async one(ctx: Context, query: Statement) {
|
|
107
|
+
const rows = await this.db.query(ctx, query);
|
|
108
|
+
|
|
109
|
+
return rows.length === 1 ? this.toEntity(rows[0]) : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
protected async all(ctx: Context, query: Statement) {
|
|
113
|
+
const rows = await this.db.query(ctx, query);
|
|
114
|
+
|
|
115
|
+
return rows.map((row) => this.toEntity(row));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
protected async count(ctx: Context, countQuery: Statement) {
|
|
119
|
+
const rows = await this.db.query(ctx, countQuery);
|
|
120
|
+
return parseInt(rows[0].count, 10);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
protected toRow(entity: Partial<T>) {
|
|
124
|
+
const row: Record<string, any> = {};
|
|
125
|
+
|
|
126
|
+
Object.keys(entity).forEach((key) => {
|
|
127
|
+
row[camelToSnakeCase(key)] = entity[key];
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return row;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
protected toEntity(row: Record<string, any>) {
|
|
134
|
+
const entity: Record<string, any> = {};
|
|
135
|
+
|
|
136
|
+
Object.keys(row).forEach((key) => {
|
|
137
|
+
const fieldName = snakeToCamelCase(key);
|
|
138
|
+
let fieldValue = row[key];
|
|
139
|
+
|
|
140
|
+
if (
|
|
141
|
+
this.dateFields.includes(fieldName) &&
|
|
142
|
+
typeof fieldValue === "number"
|
|
143
|
+
) {
|
|
144
|
+
fieldValue = new Date(fieldValue);
|
|
145
|
+
}
|
|
146
|
+
entity[fieldName] = fieldValue;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return entity as T;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
protected onBeforeInsert(ctx: Context, entity: Partial<T>) {}
|
|
153
|
+
protected onBeforeUpdate(ctx: Context, entity: Partial<T>) {}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface Page<T> {
|
|
157
|
+
items: T[];
|
|
158
|
+
}
|
package/src/Router.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Handler } from "./Handler.js";
|
|
2
|
+
|
|
3
|
+
export type HttpMethod =
|
|
4
|
+
| "GET"
|
|
5
|
+
| "POST"
|
|
6
|
+
| "PUT"
|
|
7
|
+
| "DELETE"
|
|
8
|
+
| "PATCH"
|
|
9
|
+
| "OPTIONS"
|
|
10
|
+
| "HEAD";
|
|
11
|
+
|
|
12
|
+
type RouteMatcher = (path: string) => Record<string, string> | undefined;
|
|
13
|
+
|
|
14
|
+
type Route = {
|
|
15
|
+
path: string;
|
|
16
|
+
matcher: RouteMatcher;
|
|
17
|
+
handlers: Map<HttpMethod, Handler>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function createMatcher(path: string): RouteMatcher {
|
|
21
|
+
if (!path.includes(":")) {
|
|
22
|
+
return (p: string) => {
|
|
23
|
+
return p === path ? {} : undefined;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const pathParams: string[] = [];
|
|
27
|
+
const regex = new RegExp(
|
|
28
|
+
"^" +
|
|
29
|
+
path.replace(/:(\w+)/g, (pathParam) => {
|
|
30
|
+
pathParams.push(pathParam.substring(1));
|
|
31
|
+
return "([\\w-_]+)";
|
|
32
|
+
}) +
|
|
33
|
+
"$",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return (path: string) => {
|
|
37
|
+
const match = regex.exec(path);
|
|
38
|
+
if (!match) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const params: Record<string, string> = {};
|
|
42
|
+
for (let i = 0; i < pathParams.length; i++) {
|
|
43
|
+
params[pathParams[i]] = match[i + 1];
|
|
44
|
+
}
|
|
45
|
+
return params;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class Router {
|
|
50
|
+
private routes: Route[] = [];
|
|
51
|
+
|
|
52
|
+
public registerRoute(method: HttpMethod, path: string, handler: Handler) {
|
|
53
|
+
for (const route of this.routes) {
|
|
54
|
+
if (route.path === path) {
|
|
55
|
+
route.handlers.set(method, handler);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
this.routes.push({
|
|
60
|
+
path,
|
|
61
|
+
matcher: createMatcher(path),
|
|
62
|
+
handlers: new Map([[method, handler]]),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public findRoute(method: HttpMethod, path: string) {
|
|
67
|
+
for (const route of this.routes) {
|
|
68
|
+
const pathParams = route.matcher(path);
|
|
69
|
+
if (pathParams !== undefined && route.handlers.has(method)) {
|
|
70
|
+
return {
|
|
71
|
+
handler: route.handlers.get(method)!,
|
|
72
|
+
pathParams,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|