@anteros/core 0.0.1-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -0
- package/database/collection.ts +160 -0
- package/database/decorator.ts +172 -0
- package/database/file.ts +93 -0
- package/database/mongodbadapter.ts +1128 -0
- package/database/rest.ts +14 -0
- package/database/schema.ts +160 -0
- package/database/tenant.ts +37 -0
- package/database/workflow.ts +384 -0
- package/index.ts +28 -0
- package/lib/asyncContextStorage.ts +68 -0
- package/lib/define.ts +114 -0
- package/lib/error.ts +21 -0
- package/lib/files.ts +459 -0
- package/lib/middleware.ts +66 -0
- package/lib/routes.ts +44 -0
- package/lib/scripts.ts +47 -0
- package/lib/services.ts +45 -0
- package/lib/sockets.ts +44 -0
- package/lib/workflow.ts +60 -0
- package/package.json +31 -0
- package/server/api.ts +789 -0
- package/server/boot.ts +101 -0
- package/server/config.ts +107 -0
- package/server/env.ts +16 -0
- package/server/hono.ts +176 -0
- package/server/io.ts +15 -0
- package/server/routes.ts +48 -0
- package/server/security.ts +138 -0
- package/tests/api.test.ts +281 -0
- package/tsconfig.json +36 -0
- package/types/activity.d.ts +45 -0
- package/types/api.d.ts +85 -0
- package/types/collection.d.ts +82 -0
- package/types/config.d.ts +55 -0
- package/types/field.d.ts +72 -0
- package/types/file.d.ts +120 -0
- package/types/hook.d.ts +30 -0
- package/types/middleware.d.ts +18 -0
- package/types/mongo.d.ts +61 -0
- package/types/options.d.ts +7 -0
- package/types/rest.d.ts +18 -0
- package/types/route.d.ts +19 -0
- package/types/schema.d.ts +0 -0
- package/types/scripts.d.ts +10 -0
- package/types/service.d.ts +37 -0
- package/types/task.d.ts +12 -0
- package/types/tenant.d.ts +16 -0
- package/types/token.d.ts +14 -0
- package/types/websocket.d.ts +15 -0
- package/types/workflow.d.ts +91 -0
- package/utils/cache.ts +96 -0
- package/utils/crypto.ts +226 -0
- package/utils/func.ts +1037 -0
- package/utils/index.ts +17 -0
package/server/boot.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
|
|
2
|
+
import { createApp } from './hono'
|
|
3
|
+
import type { ServerConfig } from '../types/config'
|
|
4
|
+
import dayjs from 'dayjs';
|
|
5
|
+
import pkg from '../package.json';
|
|
6
|
+
import boxen from 'boxen';
|
|
7
|
+
import "@colors/colors";
|
|
8
|
+
import { cfg, formatConfig } from './config' // import the config
|
|
9
|
+
import { syncTenants } from '../database/tenant'
|
|
10
|
+
import { syncCollections } from '../database/collection'
|
|
11
|
+
import { syncFileCollections } from '../database/file'
|
|
12
|
+
|
|
13
|
+
import { loadRoutes } from '../lib/routes'
|
|
14
|
+
import { runScripts } from '../lib/scripts'
|
|
15
|
+
import { io, engineIo, websocket } from './io'
|
|
16
|
+
import { loadServices } from '../lib/services'
|
|
17
|
+
import { loadSockets } from '../lib/sockets'
|
|
18
|
+
import { syncWorkflows } from '../lib/workflow'
|
|
19
|
+
import { loadTenantsMiddlewares } from '../lib/middleware'
|
|
20
|
+
type BootAppOptions = ServerConfig & {
|
|
21
|
+
|
|
22
|
+
}
|
|
23
|
+
async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
|
|
27
|
+
// Load required resources
|
|
28
|
+
//******************************* */
|
|
29
|
+
options = formatConfig(options);
|
|
30
|
+
await syncTenants(); // sync tenants and connect to database
|
|
31
|
+
await syncCollections(); // sync collections and create collections on database
|
|
32
|
+
await syncFileCollections(); // sync file collections
|
|
33
|
+
await loadServices(); // load services
|
|
34
|
+
await loadSockets(); // load websocket handlers
|
|
35
|
+
await syncWorkflows();
|
|
36
|
+
await loadTenantsMiddlewares();
|
|
37
|
+
loadRoutes(); // load routes
|
|
38
|
+
//******************************* */
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
const PORT = (cfg.server.port || 4000);
|
|
42
|
+
const NAME = cfg.server.name || process.env.APP_NAME || 'SERVER';
|
|
43
|
+
const useClusterMode = cfg.clusterMode || false;
|
|
44
|
+
const env = process.env.NODE_ENV || Bun.env.NODE_ENV || 'dev';
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
const app = createApp();
|
|
48
|
+
|
|
49
|
+
const server = Bun.serve({
|
|
50
|
+
port: PORT,
|
|
51
|
+
reusePort: useClusterMode,
|
|
52
|
+
fetch: (req, server) => {
|
|
53
|
+
const url = new URL(req.url);
|
|
54
|
+
|
|
55
|
+
if (url.pathname === "/socket.io/") {
|
|
56
|
+
return engineIo.handleRequest(req, server);
|
|
57
|
+
} else {
|
|
58
|
+
return app.fetch(req, server);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
},
|
|
62
|
+
websocket: websocket,
|
|
63
|
+
maxRequestBodySize: 1024 * 1024 * 100, // 100MB
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
let box = '';
|
|
67
|
+
box += `${NAME}`.gray.underline + ` (PID: ${process.pid})\n\n`
|
|
68
|
+
box += `Env: ${env || 'dev'}`.green.bold + '\n'
|
|
69
|
+
box += `Cluster mode: ${useClusterMode ? 'On'.green.bold : 'Off'.red.bold}\n`.gray.bold
|
|
70
|
+
box += `Url: http://localhost:${PORT}`.gray.bold;
|
|
71
|
+
box += '\n\n';
|
|
72
|
+
box += `Last boot: 🔄 ${dayjs().format('YYYY-MM-DD HH:mm:ss')}`.gray;
|
|
73
|
+
|
|
74
|
+
console.log(boxen(box, {
|
|
75
|
+
title: ` @anteros/core ${pkg.version}`,
|
|
76
|
+
padding: 1,
|
|
77
|
+
float: 'left',
|
|
78
|
+
borderColor: 'gray',
|
|
79
|
+
titleAlignment: 'center',
|
|
80
|
+
borderStyle: 'double',
|
|
81
|
+
textAlignment: 'left',
|
|
82
|
+
dimBorder: true
|
|
83
|
+
}))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
// After boot and App ready.
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
runScripts().catch(); // run scripts
|
|
89
|
+
|
|
90
|
+
}, 150);
|
|
91
|
+
|
|
92
|
+
return server;
|
|
93
|
+
} catch (err: any) {
|
|
94
|
+
console.error('Failed to boot server', err?.message);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export {
|
|
100
|
+
bootApp
|
|
101
|
+
}
|
package/server/config.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ServerConfig, Config } from "../types/config"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import pkg from '../package.json';
|
|
4
|
+
import { cleanDeep } from "../utils/func";
|
|
5
|
+
const cfg: Config = { // app Config
|
|
6
|
+
|
|
7
|
+
server: {
|
|
8
|
+
port: 4000,
|
|
9
|
+
},
|
|
10
|
+
tenants: []
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
function formatConfig(config: ServerConfig) { // format the config
|
|
17
|
+
|
|
18
|
+
cfg.clusterMode = config.clusterMode ?? true;
|
|
19
|
+
cfg.server = {
|
|
20
|
+
...cfg.server,
|
|
21
|
+
...config.server,
|
|
22
|
+
}
|
|
23
|
+
cfg.version = config.version;
|
|
24
|
+
cfg.tenants = config.tenants ?? [];
|
|
25
|
+
return cfg;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function loadAppConfig(serverConfig: ServerConfig) {
|
|
29
|
+
try {
|
|
30
|
+
/* const PKG =
|
|
31
|
+
await import(path.resolve(process.cwd(), 'package.json')); */
|
|
32
|
+
formatConfig({
|
|
33
|
+
...serverConfig,
|
|
34
|
+
version: pkg.version,
|
|
35
|
+
});
|
|
36
|
+
} catch (err: any) {
|
|
37
|
+
console.error('Error loading app config', err?.message);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
function safePublicConfig() {
|
|
44
|
+
return cleanDeep({
|
|
45
|
+
tenants: (cfg.tenants ?? []).map(t => ({
|
|
46
|
+
id: t.id,
|
|
47
|
+
name: t.name ?? t.id,
|
|
48
|
+
})),
|
|
49
|
+
collections: (cfg.collections ?? []).map(c => ({
|
|
50
|
+
_tenant_: c._tenant_,
|
|
51
|
+
slug: c.slug,
|
|
52
|
+
type: c.type,
|
|
53
|
+
actions: [
|
|
54
|
+
'insertOne', 'insertMany', 'updateOne', 'updateMany',
|
|
55
|
+
'deleteOne', 'deleteMany', 'findOne', 'find',
|
|
56
|
+
'findOneAndUpdate', 'aggregate',
|
|
57
|
+
...Object.keys(c.actions ?? {}),
|
|
58
|
+
],
|
|
59
|
+
fields: (c.fields ?? []).map(f => ({
|
|
60
|
+
name: f.name,
|
|
61
|
+
type: f.type,
|
|
62
|
+
description: f.description,
|
|
63
|
+
required: f.required,
|
|
64
|
+
nullable: f.nullable,
|
|
65
|
+
empty: f.empty,
|
|
66
|
+
relation: f.relation,
|
|
67
|
+
enumOptions: f.enumOptions,
|
|
68
|
+
randomOptions: f.randomOptions,
|
|
69
|
+
defaultValue: f.defaultValue,
|
|
70
|
+
studio: f.studio,
|
|
71
|
+
})),
|
|
72
|
+
readOnlyFields: c.api?.readOnlyFields,
|
|
73
|
+
studio: c.studio,
|
|
74
|
+
})),
|
|
75
|
+
services: (cfg.services ?? []).map(s => ({
|
|
76
|
+
_tenant_: s._tenant_,
|
|
77
|
+
name: s.name,
|
|
78
|
+
enabled: s.enabled,
|
|
79
|
+
actions: Object.keys(s.actions ?? {}),
|
|
80
|
+
})),
|
|
81
|
+
fileCollections: (cfg.fileCollections ?? []).map(fc => ({
|
|
82
|
+
_tenant_: fc._tenant_,
|
|
83
|
+
slug: fc.slug,
|
|
84
|
+
fields: (fc.fields ?? []).map(f => ({
|
|
85
|
+
name: f.name,
|
|
86
|
+
type: f.type,
|
|
87
|
+
description: f.description,
|
|
88
|
+
required: f.required,
|
|
89
|
+
nullable: f.nullable,
|
|
90
|
+
empty: f.empty,
|
|
91
|
+
relation: f.relation,
|
|
92
|
+
enumOptions: f.enumOptions,
|
|
93
|
+
randomOptions: f.randomOptions,
|
|
94
|
+
defaultValue: f.defaultValue,
|
|
95
|
+
studio: f.studio,
|
|
96
|
+
})),
|
|
97
|
+
readOnlyFields: fc.api?.readOnlyFields,
|
|
98
|
+
})),
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export {
|
|
103
|
+
formatConfig,
|
|
104
|
+
cfg,
|
|
105
|
+
loadAppConfig,
|
|
106
|
+
safePublicConfig
|
|
107
|
+
}
|
package/server/env.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hono context variables shared by middleware and route handlers.
|
|
3
|
+
*/
|
|
4
|
+
export type HonoVariables = {
|
|
5
|
+
/** Present when `Authorization: Bearer <jwt>` was sent and verification succeeded. */
|
|
6
|
+
token?: {
|
|
7
|
+
/** Raw JWT string from the `Authorization` header. */
|
|
8
|
+
value: string | null;
|
|
9
|
+
/** Verified payload (claims). */
|
|
10
|
+
decoded: Record<string, unknown> | null;
|
|
11
|
+
/** Whether a token was provided in the Authorization header */
|
|
12
|
+
provided: boolean;
|
|
13
|
+
/** Whether the token is expired */
|
|
14
|
+
expired: boolean;
|
|
15
|
+
};
|
|
16
|
+
};
|
package/server/hono.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { cors } from "hono/cors"
|
|
3
|
+
import { compress } from 'hono/compress'
|
|
4
|
+
import { ipRestriction } from "hono/ip-restriction"
|
|
5
|
+
import { bodyLimit } from "hono/body-limit"
|
|
6
|
+
import { secureHeaders } from "hono/secure-headers"
|
|
7
|
+
import { getConnInfo } from "hono/bun"
|
|
8
|
+
import { cfg } from "./config"
|
|
9
|
+
import { rateLimit } from "./security"
|
|
10
|
+
import { initializeRoutes } from "./routes";
|
|
11
|
+
import { sessionCtxStorage, asyncContextStorage, requestCtxStorage } from "../lib/asyncContextStorage";
|
|
12
|
+
import { initializeApi } from "./api";
|
|
13
|
+
import { getGlobalMiddlewares } from "../lib/middleware";
|
|
14
|
+
import { jwt } from "../utils/func";
|
|
15
|
+
import { AppError } from "../lib/error";
|
|
16
|
+
import type { HonoVariables } from "./env";
|
|
17
|
+
|
|
18
|
+
const app = new Hono<{ Variables: HonoVariables }>();
|
|
19
|
+
function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
20
|
+
const allowHeaders = [
|
|
21
|
+
"Tenant-Id",
|
|
22
|
+
"Content-Type",
|
|
23
|
+
"Authorization",
|
|
24
|
+
"Accept",
|
|
25
|
+
"Origin",
|
|
26
|
+
"X-Requested-With",
|
|
27
|
+
"Access-Control-Request-Method",
|
|
28
|
+
"Access-Control-Request-Headers",
|
|
29
|
+
"CF-Connecting-IP",
|
|
30
|
+
"True-Client-IP",
|
|
31
|
+
"X-Forwarded-For",
|
|
32
|
+
"Cookie",
|
|
33
|
+
"X-Forwarded-Host",
|
|
34
|
+
...(cfg.server.cors?.allowHeaders as string[] || []),
|
|
35
|
+
]
|
|
36
|
+
const allowMethods = [
|
|
37
|
+
"GET",
|
|
38
|
+
"POST",
|
|
39
|
+
"PUT",
|
|
40
|
+
"DELETE",
|
|
41
|
+
"OPTIONS",
|
|
42
|
+
"PATCH",
|
|
43
|
+
"HEAD",
|
|
44
|
+
...(cfg.server.cors?.allowMethods as string[] || []),
|
|
45
|
+
]
|
|
46
|
+
// cors
|
|
47
|
+
const corsOrigin = cfg.server.cors?.origin as string[] | undefined;
|
|
48
|
+
const corsCredentials = cfg.server.cors?.credentials as boolean | undefined ?? false;
|
|
49
|
+
|
|
50
|
+
if (corsCredentials && (!corsOrigin || corsOrigin.includes('*'))) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
'CORS misconfiguration: credentials: true is incompatible with origin: *. '
|
|
53
|
+
+ 'Set explicit origins in cfg.server.cors.origin.'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
app.use(cors({
|
|
60
|
+
origin: corsOrigin || ['*'],
|
|
61
|
+
credentials: corsCredentials,
|
|
62
|
+
allowMethods: allowMethods,
|
|
63
|
+
allowHeaders: allowHeaders,
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
// compress
|
|
67
|
+
app.use(compress());
|
|
68
|
+
|
|
69
|
+
// ip restriction
|
|
70
|
+
app.use(ipRestriction(getConnInfo, {
|
|
71
|
+
denyList: cfg.server?.ipRestriction?.denyList || [],
|
|
72
|
+
allowList: cfg.server?.ipRestriction?.allowList || [],
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
// security headers
|
|
76
|
+
app.use(secureHeaders({
|
|
77
|
+
strictTransportSecurity: true,
|
|
78
|
+
xFrameOptions: true,
|
|
79
|
+
xContentTypeOptions: true,
|
|
80
|
+
xXssProtection: true,
|
|
81
|
+
referrerPolicy: true,
|
|
82
|
+
removePoweredBy: true,
|
|
83
|
+
}))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
// body limit
|
|
87
|
+
app.use(bodyLimit({
|
|
88
|
+
maxSize: cfg.server.body?.maxSize ?? 1024 * 1024 * 100, // 100MB
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
// rate limiting (global)
|
|
94
|
+
if (cfg.server.rateLimit?.enabled !== false) {
|
|
95
|
+
app.use('*', rateLimit({
|
|
96
|
+
windowMs: cfg.server.rateLimit?.windowMs ?? 60_000,
|
|
97
|
+
max: cfg.server.rateLimit?.max ?? 100,
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
// stricter limit for login endpoints
|
|
101
|
+
app.use('/api/*/login', rateLimit({
|
|
102
|
+
windowMs: cfg.server.rateLimit?.login?.windowMs ?? 60_000,
|
|
103
|
+
max: cfg.server.rateLimit?.login?.max ?? 10,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// async Context Storage
|
|
108
|
+
app.use(async (c, next) => {
|
|
109
|
+
return asyncContextStorage.run(new Map(), async () => {
|
|
110
|
+
const traceId = crypto.randomUUID();
|
|
111
|
+
const connInfo = getConnInfo(c);
|
|
112
|
+
requestCtxStorage.set('trace', { id: traceId });
|
|
113
|
+
requestCtxStorage.set('internal', false);
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
requestCtxStorage.set('meta', {
|
|
118
|
+
request: {
|
|
119
|
+
ip: connInfo.remote.address ?? c.req.header('CF-Connecting-IP') ?? c.req.header('True-Client-IP') ?? c.req.header('X-Forwarded-For') ?? 'unknown',
|
|
120
|
+
user_agent: c.req.header('User-Agent') ?? '',
|
|
121
|
+
headers: c.req.header(),
|
|
122
|
+
method: c.req.method,
|
|
123
|
+
path: c.req.path,
|
|
124
|
+
query: c.req.query(),
|
|
125
|
+
},
|
|
126
|
+
environment: Bun.env.NODE_ENV || process.env.NODE_ENV || 'dev',
|
|
127
|
+
hostname: Bun.env.HOSTNAME || process.env.HOSTNAME || 'localhost',
|
|
128
|
+
platform: Bun.env.PLATFORM || process.env.PLATFORM || 'unknown',
|
|
129
|
+
})
|
|
130
|
+
const bearer = c.req.header('Authorization')?.replace('Bearer ', '');
|
|
131
|
+
if (bearer) {
|
|
132
|
+
const { value, error } = await jwt.verify(bearer);
|
|
133
|
+
const isValid = !error && value != null;
|
|
134
|
+
const isExpired = !!error && error.toLowerCase().includes('exp');
|
|
135
|
+
|
|
136
|
+
const tokenData = {
|
|
137
|
+
value: bearer,
|
|
138
|
+
decoded: isValid ? (value as Record<string, unknown>) : null,
|
|
139
|
+
provided: true,
|
|
140
|
+
expired: isExpired || (value != null && typeof (value as any)?.exp === 'number' && (value as any).exp * 1000 < Date.now()),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
requestCtxStorage.set('token', tokenData);
|
|
144
|
+
c.set('token', tokenData);
|
|
145
|
+
} else {
|
|
146
|
+
const emptyToken = { value: null, decoded: null, provided: false, expired: false };
|
|
147
|
+
requestCtxStorage.set('token', emptyToken);
|
|
148
|
+
c.set('token', emptyToken);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return await next();
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// Global user-defined middlewares — has access to requestCtxStorage, asyncContextStorage, etc.
|
|
156
|
+
for (const mw of getGlobalMiddlewares()) {
|
|
157
|
+
app.use(mw.handler);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// initialize routes
|
|
161
|
+
initializeRoutes(app);
|
|
162
|
+
|
|
163
|
+
// initialize api
|
|
164
|
+
initializeApi(app);
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
return app;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
export {
|
|
174
|
+
createApp,
|
|
175
|
+
app
|
|
176
|
+
}
|
package/server/io.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Server as Engine } from "@socket.io/bun-engine";
|
|
2
|
+
import { Server } from "socket.io";
|
|
3
|
+
|
|
4
|
+
const io = new Server();
|
|
5
|
+
const engineIo = new Engine();
|
|
6
|
+
io.bind(engineIo);
|
|
7
|
+
const { websocket } = engineIo.handler()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
io,
|
|
13
|
+
engineIo,
|
|
14
|
+
websocket
|
|
15
|
+
}
|
package/server/routes.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import { cfg } from "./config";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { jwt } from "../utils/func";
|
|
5
|
+
import { useRest } from "../database/rest";
|
|
6
|
+
import { io } from "./io";
|
|
7
|
+
import type { HonoVariables } from "./env";
|
|
8
|
+
|
|
9
|
+
function initializeRoutes(app: Hono<{ Variables: HonoVariables }>) {
|
|
10
|
+
for (let route of cfg.routes ?? []) {
|
|
11
|
+
if (route._prefix_) {
|
|
12
|
+
let method = route.method.toLocaleLowerCase();
|
|
13
|
+
let routePath = path.posix.join(route._prefix_, route.path);
|
|
14
|
+
|
|
15
|
+
if (route.method == "GET") {
|
|
16
|
+
app.get(routePath, async (c) => { //
|
|
17
|
+
return route.handler({
|
|
18
|
+
c,
|
|
19
|
+
rest: new useRest({
|
|
20
|
+
tenant_id: route._tenant_,
|
|
21
|
+
}),
|
|
22
|
+
jwt: jwt,
|
|
23
|
+
io: io,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (route.method == 'POST') {
|
|
29
|
+
app.post(routePath, async (c) => {
|
|
30
|
+
return route.handler({
|
|
31
|
+
c,
|
|
32
|
+
rest: new useRest({
|
|
33
|
+
tenant_id: route._tenant_,
|
|
34
|
+
}),
|
|
35
|
+
jwt: jwt,
|
|
36
|
+
io: io,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
initializeRoutes
|
|
48
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { getConnInfo } from "hono/bun";
|
|
3
|
+
|
|
4
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export type RateLimitStore = {
|
|
7
|
+
increment(key: string, windowMs: number): Promise<{ count: number; resetAt: number }>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type RateLimitOptions = {
|
|
11
|
+
windowMs?: number;
|
|
12
|
+
max?: number;
|
|
13
|
+
message?: string;
|
|
14
|
+
code?: string;
|
|
15
|
+
statusCode?: number;
|
|
16
|
+
keyGenerator?: (c: Context) => string | Promise<string>;
|
|
17
|
+
store?: RateLimitStore;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type RedisClient = {
|
|
21
|
+
incr(key: string): Promise<number>;
|
|
22
|
+
pexpire(key: string, ms: number): Promise<number>;
|
|
23
|
+
pttl(key: string): Promise<number>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ─── In-Memory Store ─────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
interface MemoryEntry {
|
|
29
|
+
count: number;
|
|
30
|
+
resetAt: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createMemoryStore(cleanupIntervalMs = 60000): RateLimitStore & { dispose(): void } {
|
|
34
|
+
const store = new Map<string, MemoryEntry>();
|
|
35
|
+
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
36
|
+
|
|
37
|
+
const cleanup = () => {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
for (const [key, entry] of store) {
|
|
40
|
+
if (entry.resetAt <= now) {
|
|
41
|
+
store.delete(key);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
cleanupTimer = setInterval(cleanup, cleanupIntervalMs);
|
|
47
|
+
if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
|
|
48
|
+
(cleanupTimer as any).unref();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
async increment(key: string, windowMs: number) {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
const entry = store.get(key);
|
|
55
|
+
|
|
56
|
+
if (!entry || entry.resetAt <= now) {
|
|
57
|
+
const resetAt = now + windowMs;
|
|
58
|
+
store.set(key, { count: 1, resetAt });
|
|
59
|
+
return { count: 1, resetAt };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
entry.count += 1;
|
|
63
|
+
return { count: entry.count, resetAt: entry.resetAt };
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
dispose() {
|
|
67
|
+
if (cleanupTimer) {
|
|
68
|
+
clearInterval(cleanupTimer);
|
|
69
|
+
cleanupTimer = null;
|
|
70
|
+
}
|
|
71
|
+
store.clear();
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Redis Store ─────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export function createRedisStore(redis: RedisClient): RateLimitStore {
|
|
79
|
+
return {
|
|
80
|
+
async increment(key: string, windowMs: number) {
|
|
81
|
+
const count = await redis.incr(key);
|
|
82
|
+
|
|
83
|
+
// First increment in this window – set the expiry
|
|
84
|
+
if (count === 1) {
|
|
85
|
+
await redis.pexpire(key, windowMs);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ttl = await redis.pttl(key);
|
|
89
|
+
const resetAt = Date.now() + Math.max(0, ttl);
|
|
90
|
+
|
|
91
|
+
return { count, resetAt };
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Default key generator ───────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function defaultKeyGenerator(c: Context): string {
|
|
99
|
+
const connInfo = getConnInfo(c);
|
|
100
|
+
const ip =
|
|
101
|
+
connInfo.remote.address ??
|
|
102
|
+
c.req.header("CF-Connecting-IP") ??
|
|
103
|
+
c.req.header("True-Client-IP") ??
|
|
104
|
+
c.req.header("X-Forwarded-For") ??
|
|
105
|
+
"unknown";
|
|
106
|
+
|
|
107
|
+
return `rn:rl:${ip}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Middleware ──────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export function rateLimit(opts: RateLimitOptions = {}) {
|
|
113
|
+
const windowMs = opts.windowMs ?? 60_000;
|
|
114
|
+
const max = opts.max ?? 60;
|
|
115
|
+
const message = opts.message ?? "Too many requests, please try again later";
|
|
116
|
+
const code = opts.code ?? "RATE_LIMIT_EXCEEDED";
|
|
117
|
+
const statusCode = opts.statusCode ?? 429;
|
|
118
|
+
const keyGenerator = opts.keyGenerator ?? defaultKeyGenerator;
|
|
119
|
+
const store = opts.store ?? createMemoryStore();
|
|
120
|
+
|
|
121
|
+
return async (c: Context, next: () => Promise<void>): Promise<Response | void> => {
|
|
122
|
+
const key = await keyGenerator(c);
|
|
123
|
+
const { count, resetAt } = await store.increment(key, windowMs);
|
|
124
|
+
const remaining = Math.max(0, max - count);
|
|
125
|
+
|
|
126
|
+
c.header("X-RateLimit-Limit", String(max));
|
|
127
|
+
c.header("X-RateLimit-Remaining", String(remaining));
|
|
128
|
+
c.header("X-RateLimit-Reset", String(Math.ceil(resetAt / 1000)));
|
|
129
|
+
|
|
130
|
+
if (count > max) {
|
|
131
|
+
const retryAfter = Math.ceil((resetAt - Date.now()) / 1000);
|
|
132
|
+
c.header("Retry-After", String(retryAfter));
|
|
133
|
+
return c.json({ message, code }, statusCode as any);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
await next();
|
|
137
|
+
};
|
|
138
|
+
}
|