arcway 0.4.20 → 0.5.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/package.json +1 -1
- package/server/bin/cli.js +2 -0
- package/server/bin/commands/env.js +30 -0
- package/server/bin/solo.js +6 -1
- package/server/boot/index.js +4 -0
- package/server/config/loader.js +2 -0
- package/server/config/modules/env.js +68 -0
- package/server/config/modules/mail.js +2 -0
- package/server/context.js +2 -0
- package/server/health.js +9 -0
- package/server/jobs/runner.js +3 -0
- package/server/jobs/worker-entry.js +8 -1
- package/server/mail/index.js +49 -5
- package/server/queue/drivers/knex.js +86 -4
- package/server/queue/drivers/redis.js +148 -40
- package/server/queue/index.js +29 -1
- package/server/rate-limit/index.js +4 -2
- package/server/router/api-router.js +16 -6
- package/server/router/ratelimit-key.js +39 -0
- package/server/router/ratelimit.js +10 -5
- package/server/router/routes.js +9 -0
- package/server/testing/index.js +2 -0
package/package.json
CHANGED
package/server/bin/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import registerSchema from './commands/schema.js';
|
|
|
13
13
|
import registerMigrate from './commands/migrate.js';
|
|
14
14
|
import registerBootstrap from './commands/bootstrap.js';
|
|
15
15
|
import registerVault from './commands/vault.js';
|
|
16
|
+
import registerEnv from './commands/env.js';
|
|
16
17
|
|
|
17
18
|
function getPackageVersion() {
|
|
18
19
|
try {
|
|
@@ -40,6 +41,7 @@ function createProgram() {
|
|
|
40
41
|
registerMigrate(program);
|
|
41
42
|
registerBootstrap(program);
|
|
42
43
|
registerVault(program);
|
|
44
|
+
registerEnv(program);
|
|
43
45
|
return program;
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { makeConfig } from '#server/config/loader.js';
|
|
2
|
+
import { loadEnvFiles } from '#server/env.js';
|
|
3
|
+
|
|
4
|
+
async function runEnvCheck(options = {}) {
|
|
5
|
+
const rootDir = process.cwd();
|
|
6
|
+
const mode =
|
|
7
|
+
options.mode ?? (process.env.NODE_ENV === 'production' ? 'production' : 'development');
|
|
8
|
+
loadEnvFiles(rootDir, mode);
|
|
9
|
+
await makeConfig(rootDir, { mode });
|
|
10
|
+
console.log(`Environment is valid for ${mode}.`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function register(program) {
|
|
14
|
+
const env = program.command('env').description('Environment declaration utilities');
|
|
15
|
+
env
|
|
16
|
+
.command('check')
|
|
17
|
+
.description('Validate declared environment variables without starting the application')
|
|
18
|
+
.option('--mode <mode>', 'configuration mode')
|
|
19
|
+
.action(async (options) => {
|
|
20
|
+
try {
|
|
21
|
+
await runEnvCheck(options);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { runEnvCheck };
|
|
30
|
+
export default register;
|
package/server/bin/solo.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createProgram } from './cli.js';
|
|
3
3
|
const program = createProgram();
|
|
4
|
-
|
|
4
|
+
try {
|
|
5
|
+
await program.parseAsync(process.argv);
|
|
6
|
+
} catch (error) {
|
|
7
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
8
|
+
process.exitCode = 1;
|
|
9
|
+
}
|
package/server/boot/index.js
CHANGED
|
@@ -56,6 +56,8 @@ async function boot(options) {
|
|
|
56
56
|
// the init hook (e.g. wrapping `appContext.db`) persist for process lifetime
|
|
57
57
|
// because downstream consumers read properties off this reference at use time.
|
|
58
58
|
const appContext = {
|
|
59
|
+
config,
|
|
60
|
+
env: config.env,
|
|
59
61
|
db,
|
|
60
62
|
redis,
|
|
61
63
|
events,
|
|
@@ -112,6 +114,7 @@ async function boot(options) {
|
|
|
112
114
|
workerPool,
|
|
113
115
|
plugins,
|
|
114
116
|
appConfig: config,
|
|
117
|
+
env: config.env,
|
|
115
118
|
});
|
|
116
119
|
await jobRunner.init();
|
|
117
120
|
|
|
@@ -140,6 +143,7 @@ async function boot(options) {
|
|
|
140
143
|
const healthDeps = {
|
|
141
144
|
db,
|
|
142
145
|
redisClients: [{ name: 'redis', client: redis?.client }],
|
|
146
|
+
mail,
|
|
143
147
|
};
|
|
144
148
|
const callbackHandlers = await discoverRootCallbacks(rootDir);
|
|
145
149
|
const pluginCallbacks = (await plugins.discoverCallbacks?.()) ?? new Map();
|
package/server/config/loader.js
CHANGED
|
@@ -20,6 +20,7 @@ import resolveMcp from './modules/mcp.js';
|
|
|
20
20
|
import resolveWebsocket from './modules/websocket.js';
|
|
21
21
|
import resolveSeeds from './modules/seeds.js';
|
|
22
22
|
import resolvePlugins from './modules/plugins.js';
|
|
23
|
+
import resolveEnv from './modules/env.js';
|
|
23
24
|
|
|
24
25
|
function deepMerge(target, source) {
|
|
25
26
|
const result = { ...target };
|
|
@@ -40,6 +41,7 @@ function deepMerge(target, source) {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
const modules = [
|
|
44
|
+
resolveEnv,
|
|
43
45
|
resolveServer,
|
|
44
46
|
resolveApi,
|
|
45
47
|
resolveDatabase,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
function isPlainObject(value) {
|
|
2
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function describe(name, declaration, issue) {
|
|
6
|
+
return declaration?.description
|
|
7
|
+
? `${name}: ${declaration.description} — ${issue}`
|
|
8
|
+
: `${name}: ${issue}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function resolve(config, { mode } = {}) {
|
|
12
|
+
if (config.env === undefined) return { ...config, env: Object.freeze({}) };
|
|
13
|
+
if (!isPlainObject(config.env)) {
|
|
14
|
+
throw new Error('Invalid environment configuration:\nenv: must be an object');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const errors = [];
|
|
18
|
+
const resolved = {};
|
|
19
|
+
for (const [name, declaration] of Object.entries(config.env)) {
|
|
20
|
+
if (!isPlainObject(declaration)) {
|
|
21
|
+
errors.push(`${name}: declaration must be an object`);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { required, default: defaultValue, values, description } = declaration;
|
|
26
|
+
const requiredValid =
|
|
27
|
+
required === undefined ||
|
|
28
|
+
typeof required === 'boolean' ||
|
|
29
|
+
(Array.isArray(required) && required.every((value) => typeof value === 'string'));
|
|
30
|
+
if (!requiredValid)
|
|
31
|
+
errors.push(
|
|
32
|
+
describe(name, declaration, 'required must be true, false, or an array of modes'),
|
|
33
|
+
);
|
|
34
|
+
if (description !== undefined && typeof description !== 'string') {
|
|
35
|
+
errors.push(`${name}: description must be a string`);
|
|
36
|
+
}
|
|
37
|
+
if (
|
|
38
|
+
values !== undefined &&
|
|
39
|
+
(!Array.isArray(values) || !values.every((value) => typeof value === 'string'))
|
|
40
|
+
) {
|
|
41
|
+
errors.push(describe(name, declaration, 'values must be an array of strings'));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isRequired = required === true || (Array.isArray(required) && required.includes(mode));
|
|
45
|
+
if (required && defaultValue !== undefined) {
|
|
46
|
+
errors.push(describe(name, declaration, 'required variables cannot declare a default'));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let value = process.env[name];
|
|
50
|
+
if (value === undefined && !required && defaultValue !== undefined) {
|
|
51
|
+
value = String(defaultValue);
|
|
52
|
+
process.env[name] = value;
|
|
53
|
+
}
|
|
54
|
+
if (value === undefined && isRequired) {
|
|
55
|
+
errors.push(describe(name, declaration, 'is required'));
|
|
56
|
+
} else if (value !== undefined && Array.isArray(values) && !values.includes(value)) {
|
|
57
|
+
errors.push(describe(name, declaration, `has invalid value ${JSON.stringify(value)}`));
|
|
58
|
+
}
|
|
59
|
+
resolved[name] = value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (errors.length > 0) {
|
|
63
|
+
throw new Error(`Invalid environment configuration:\n${errors.join('\n')}`);
|
|
64
|
+
}
|
|
65
|
+
return { ...config, env: Object.freeze(resolved) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default resolve;
|
package/server/context.js
CHANGED
package/server/health.js
CHANGED
|
@@ -28,6 +28,15 @@ async function checkHealth(deps) {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
+
if (deps.mail?.enabled) {
|
|
32
|
+
try {
|
|
33
|
+
const stats = await deps.mail.stats();
|
|
34
|
+
const failed = stats.failed ?? 0;
|
|
35
|
+
components.mail = { status: failed > 0 ? 'error' : 'ok', failed };
|
|
36
|
+
} catch (err) {
|
|
37
|
+
components.mail = { status: 'error', failed: null, error: toErrorMessage(err) };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
31
40
|
const allOk = Object.values(components).every((c) => c.status === 'ok');
|
|
32
41
|
return { status: allOk ? 'ok' : 'degraded', components };
|
|
33
42
|
}
|
package/server/jobs/runner.js
CHANGED
|
@@ -79,7 +79,14 @@ async function runTask(task) {
|
|
|
79
79
|
secret: resolvedConfig?.vault?.values?.callback,
|
|
80
80
|
});
|
|
81
81
|
const ctx = buildContext(
|
|
82
|
-
{
|
|
82
|
+
{
|
|
83
|
+
...services,
|
|
84
|
+
config: resolvedConfig,
|
|
85
|
+
env: resolvedConfig?.env,
|
|
86
|
+
meta: workerData?.meta,
|
|
87
|
+
vault: resolvedConfig?.vault,
|
|
88
|
+
callbacks,
|
|
89
|
+
},
|
|
83
90
|
{ payload },
|
|
84
91
|
);
|
|
85
92
|
return fn(ctx);
|
package/server/mail/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { addSystemJobEntry } from '../system-jobs/index.js';
|
|
|
3
3
|
import ConsoleMailDriver from './drivers/console.js';
|
|
4
4
|
import SmtpMailDriver from './drivers/smtp.js';
|
|
5
5
|
import { initInboundMail } from './inbound.js';
|
|
6
|
+
import { toErrorMessage } from '../helpers.js';
|
|
6
7
|
|
|
7
8
|
const MAIL_JOB_NAME = 'send-mail';
|
|
8
9
|
const MAIL_JOB_QUALIFIED = `${SYSTEM_JOB_DOMAIN}/${MAIL_JOB_NAME}`;
|
|
@@ -25,7 +26,7 @@ class Mail {
|
|
|
25
26
|
|
|
26
27
|
if (this._enabled) {
|
|
27
28
|
this._driver = createDriver(config);
|
|
28
|
-
registerSendJob(this._driver, queue, config
|
|
29
|
+
registerSendJob(this._driver, queue, config, log);
|
|
29
30
|
log?.info(`Mail driver: ${config.driver}`);
|
|
30
31
|
} else if (config) {
|
|
31
32
|
log?.info('Mail: disabled');
|
|
@@ -69,6 +70,23 @@ class Mail {
|
|
|
69
70
|
await initInboundMail(this._db, this._config.inbound, this._log, onEmail);
|
|
70
71
|
this._log?.info(`Mail inbound: ${this._config.inbound.driver}`);
|
|
71
72
|
}
|
|
73
|
+
|
|
74
|
+
async pending(options) {
|
|
75
|
+
return this._queue?.list(MAIL_TOPIC, { ...options, status: 'available' }) ?? [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async failed(options) {
|
|
79
|
+
return this._queue?.list(MAIL_TOPIC, { ...options, status: 'failed' }) ?? [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async retry(ids) {
|
|
83
|
+
if (!this._queue) throw new Error('Cannot retry mail: queue is not available.');
|
|
84
|
+
await this._queue.retry(ids);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async stats() {
|
|
88
|
+
return this._queue?.count(MAIL_TOPIC) ?? {};
|
|
89
|
+
}
|
|
72
90
|
}
|
|
73
91
|
|
|
74
92
|
function createDriver(config) {
|
|
@@ -86,7 +104,16 @@ function createDriver(config) {
|
|
|
86
104
|
}
|
|
87
105
|
}
|
|
88
106
|
|
|
89
|
-
function
|
|
107
|
+
function recipientDomain(to) {
|
|
108
|
+
const recipient = Array.isArray(to) ? to[0] : to;
|
|
109
|
+
return typeof recipient === 'string' && recipient.includes('@')
|
|
110
|
+
? recipient.slice(recipient.lastIndexOf('@') + 1).toLowerCase()
|
|
111
|
+
: 'unknown';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function registerSendJob(driver, queue, config, log) {
|
|
115
|
+
const maxAttempts = config.maxAttempts ?? 5;
|
|
116
|
+
const backoffMs = config.backoffMs ?? [60e3, 300e3, 1800e3, 7200e3, 21600e3];
|
|
90
117
|
addSystemJobEntry({
|
|
91
118
|
definition: {
|
|
92
119
|
name: MAIL_JOB_NAME,
|
|
@@ -96,11 +123,28 @@ function registerSendJob(driver, queue, throughput) {
|
|
|
96
123
|
if (!queue) return;
|
|
97
124
|
const items = await queue.pop(MAIL_TOPIC, 10);
|
|
98
125
|
for (const item of items) {
|
|
99
|
-
|
|
100
|
-
|
|
126
|
+
try {
|
|
127
|
+
await driver.send(item.data);
|
|
128
|
+
await queue.remove([item.id]);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
const message = toErrorMessage(error);
|
|
131
|
+
const details = {
|
|
132
|
+
messageId: item.id,
|
|
133
|
+
recipientDomain: recipientDomain(item.data.to),
|
|
134
|
+
attempt: item.attempts,
|
|
135
|
+
error: message,
|
|
136
|
+
};
|
|
137
|
+
log?.error('Queued mail delivery failed', details);
|
|
138
|
+
if (item.attempts >= maxAttempts) {
|
|
139
|
+
await queue.fail([item.id], { error: message });
|
|
140
|
+
} else {
|
|
141
|
+
const retryInMs = backoffMs[Math.min(item.attempts - 1, backoffMs.length - 1)];
|
|
142
|
+
await queue.release([item.id], { error: message, retryInMs });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
101
145
|
}
|
|
102
146
|
},
|
|
103
|
-
throughput,
|
|
147
|
+
throughput: config.throughput,
|
|
104
148
|
},
|
|
105
149
|
shouldRegister: (config) => !!config.mail,
|
|
106
150
|
description: 'Send queued outbound email',
|
|
@@ -21,10 +21,30 @@ class KnexQueueDriver {
|
|
|
21
21
|
table.string('topic').notNullable().index();
|
|
22
22
|
table.text('payload').notNullable();
|
|
23
23
|
table.string('status').notNullable().defaultTo('available');
|
|
24
|
+
table.integer('attempts').notNullable().defaultTo(0);
|
|
25
|
+
table.timestamp('available_at').notNullable().defaultTo(this.db.fn.now());
|
|
26
|
+
table.text('last_error').nullable();
|
|
24
27
|
table.timestamp('locked_at').nullable();
|
|
25
28
|
table.timestamp('created_at').defaultTo(this.db.fn.now());
|
|
26
29
|
});
|
|
30
|
+
return;
|
|
27
31
|
}
|
|
32
|
+
const additions = [
|
|
33
|
+
['attempts', (table) => table.integer('attempts').notNullable().defaultTo(0)],
|
|
34
|
+
// SQLite cannot add a column with a non-constant CURRENT_TIMESTAMP
|
|
35
|
+
// default. Backfill it below after adding a nullable column; newly
|
|
36
|
+
// created tables still get the non-null default above.
|
|
37
|
+
['available_at', (table) => table.timestamp('available_at').nullable()],
|
|
38
|
+
['last_error', (table) => table.text('last_error').nullable()],
|
|
39
|
+
];
|
|
40
|
+
for (const [column, add] of additions) {
|
|
41
|
+
if (!(await this.db.schema.hasColumn(this.tableName, column))) {
|
|
42
|
+
await this.db.schema.alterTable(this.tableName, add);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
await this.db(this.tableName)
|
|
46
|
+
.whereNull('available_at')
|
|
47
|
+
.update({ available_at: this.db.fn.now() });
|
|
28
48
|
}
|
|
29
49
|
async push(namespace, topic, payload) {
|
|
30
50
|
await this.db(this.tableName).insert({
|
|
@@ -32,6 +52,8 @@ class KnexQueueDriver {
|
|
|
32
52
|
topic,
|
|
33
53
|
payload,
|
|
34
54
|
status: 'available',
|
|
55
|
+
attempts: 0,
|
|
56
|
+
available_at: toDatabaseTimestamp(this.db, Date.now()),
|
|
35
57
|
});
|
|
36
58
|
}
|
|
37
59
|
async pushBulk(namespace, topic, payloads) {
|
|
@@ -40,6 +62,8 @@ class KnexQueueDriver {
|
|
|
40
62
|
topic,
|
|
41
63
|
payload,
|
|
42
64
|
status: 'available',
|
|
65
|
+
attempts: 0,
|
|
66
|
+
available_at: toDatabaseTimestamp(this.db, Date.now()),
|
|
43
67
|
}));
|
|
44
68
|
await this.db(this.tableName).insert(rows);
|
|
45
69
|
}
|
|
@@ -51,13 +75,15 @@ class KnexQueueDriver {
|
|
|
51
75
|
.where('namespace', namespace)
|
|
52
76
|
.andWhere('topic', topic)
|
|
53
77
|
.andWhere(function () {
|
|
54
|
-
this.where(
|
|
78
|
+
this.where(function () {
|
|
79
|
+
this.where('status', 'available').andWhere('available_at', '<=', now);
|
|
80
|
+
}).orWhere(function () {
|
|
55
81
|
this.where('status', 'locked').andWhere('locked_at', '<', cutoff);
|
|
56
82
|
});
|
|
57
83
|
})
|
|
58
84
|
.orderBy('id', 'asc')
|
|
59
85
|
.limit(count)
|
|
60
|
-
.select('id', 'payload');
|
|
86
|
+
.select('id', 'payload', 'attempts', 'last_error');
|
|
61
87
|
const client = trx.client.config.client;
|
|
62
88
|
if (client === 'mysql' || client === 'mysql2' || client === 'pg') {
|
|
63
89
|
readyQuery = readyQuery.forUpdate().skipLocked();
|
|
@@ -70,13 +96,69 @@ class KnexQueueDriver {
|
|
|
70
96
|
'id',
|
|
71
97
|
rows.map((row) => row.id),
|
|
72
98
|
)
|
|
73
|
-
.update({
|
|
74
|
-
|
|
99
|
+
.update({
|
|
100
|
+
status: 'locked',
|
|
101
|
+
locked_at: now,
|
|
102
|
+
attempts: trx.raw('?? + 1', ['attempts']),
|
|
103
|
+
});
|
|
104
|
+
return rows.map((row) => ({
|
|
105
|
+
id: row.id,
|
|
106
|
+
payload: row.payload,
|
|
107
|
+
attempts: Number(row.attempts) + 1,
|
|
108
|
+
lastError: row.last_error,
|
|
109
|
+
}));
|
|
75
110
|
});
|
|
76
111
|
}
|
|
77
112
|
async remove(namespace, ids) {
|
|
78
113
|
if (ids.length === 0) return;
|
|
79
114
|
await this.db(this.tableName).where('namespace', namespace).whereIn('id', ids).delete();
|
|
80
115
|
}
|
|
116
|
+
async release(namespace, ids, { error, retryInMs = 0 } = {}) {
|
|
117
|
+
if (ids.length === 0) return;
|
|
118
|
+
await this.db(this.tableName)
|
|
119
|
+
.where({ namespace })
|
|
120
|
+
.whereIn('id', ids)
|
|
121
|
+
.update({
|
|
122
|
+
status: 'available',
|
|
123
|
+
locked_at: null,
|
|
124
|
+
available_at: toDatabaseTimestamp(this.db, Date.now() + retryInMs),
|
|
125
|
+
last_error: error ?? null,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async fail(namespace, ids, { error } = {}) {
|
|
129
|
+
if (ids.length === 0) return;
|
|
130
|
+
await this.db(this.tableName)
|
|
131
|
+
.where({ namespace })
|
|
132
|
+
.whereIn('id', ids)
|
|
133
|
+
.update({ status: 'failed', locked_at: null, last_error: error ?? null });
|
|
134
|
+
}
|
|
135
|
+
async retry(namespace, ids) {
|
|
136
|
+
if (ids.length === 0) return;
|
|
137
|
+
await this.db(this.tableName)
|
|
138
|
+
.where({ namespace, status: 'failed' })
|
|
139
|
+
.whereIn('id', ids)
|
|
140
|
+
.update({
|
|
141
|
+
status: 'available',
|
|
142
|
+
attempts: 0,
|
|
143
|
+
available_at: toDatabaseTimestamp(this.db, Date.now()),
|
|
144
|
+
last_error: null,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
async list(namespace, topic, { status, limit = 100 } = {}) {
|
|
148
|
+
const query = this.db(this.tableName)
|
|
149
|
+
.where({ namespace, topic })
|
|
150
|
+
.orderBy('id', 'asc')
|
|
151
|
+
.limit(limit);
|
|
152
|
+
if (status) query.andWhere({ status });
|
|
153
|
+
return query.select('id', 'payload', 'status', 'attempts', 'available_at', 'last_error');
|
|
154
|
+
}
|
|
155
|
+
async count(namespace, topic) {
|
|
156
|
+
const rows = await this.db(this.tableName)
|
|
157
|
+
.where({ namespace, topic })
|
|
158
|
+
.groupBy('status')
|
|
159
|
+
.select('status')
|
|
160
|
+
.count({ count: '*' });
|
|
161
|
+
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
|
|
162
|
+
}
|
|
81
163
|
}
|
|
82
164
|
export default KnexQueueDriver;
|
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
import { REDIS_SCAN_COUNT } from '../../constants.js';
|
|
2
|
+
|
|
2
3
|
const REQUEUE_EXPIRED_LUA = `
|
|
3
4
|
local expired = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
|
|
4
|
-
for
|
|
5
|
-
redis.call('
|
|
5
|
+
for _, member in ipairs(expired) do
|
|
6
|
+
redis.call('ZADD', KEYS[2], ARGV[1], member)
|
|
6
7
|
redis.call('ZREM', KEYS[1], member)
|
|
7
8
|
end
|
|
8
9
|
return #expired
|
|
9
10
|
`;
|
|
11
|
+
|
|
10
12
|
const POP_AND_LOCK_LUA = `
|
|
11
|
-
local
|
|
12
|
-
if
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
local rows = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 1)
|
|
14
|
+
if #rows == 0 then return nil end
|
|
15
|
+
local raw = rows[1]
|
|
16
|
+
redis.call('ZREM', KEYS[1], raw)
|
|
17
|
+
local item = cjson.decode(raw)
|
|
18
|
+
item.attempts = (item.attempts or 0) + 1
|
|
19
|
+
local updated = cjson.encode(item)
|
|
20
|
+
redis.call('ZADD', KEYS[2], ARGV[1], updated)
|
|
21
|
+
return updated
|
|
16
22
|
`;
|
|
23
|
+
|
|
17
24
|
class RedisQueueDriver {
|
|
18
25
|
client;
|
|
19
26
|
prefix;
|
|
@@ -21,66 +28,167 @@ class RedisQueueDriver {
|
|
|
21
28
|
this.client = client;
|
|
22
29
|
this.prefix = `${keyPrefix}queue:`;
|
|
23
30
|
}
|
|
24
|
-
|
|
25
|
-
return `${this.prefix}${namespace}:${topic}`;
|
|
31
|
+
availableKey(namespace, topic) {
|
|
32
|
+
return `${this.prefix}${namespace}:${topic}:available`;
|
|
26
33
|
}
|
|
27
34
|
lockedKey(namespace, topic) {
|
|
28
35
|
return `${this.prefix}${namespace}:${topic}:locked`;
|
|
29
36
|
}
|
|
37
|
+
failedKey(namespace, topic) {
|
|
38
|
+
return `${this.prefix}${namespace}:${topic}:failed`;
|
|
39
|
+
}
|
|
30
40
|
idKey() {
|
|
31
41
|
return `${this.prefix}id`;
|
|
32
42
|
}
|
|
33
43
|
async init() {}
|
|
34
44
|
|
|
45
|
+
item(id, payload, availableAt = Date.now()) {
|
|
46
|
+
return JSON.stringify({ id, payload, attempts: 0, availableAt, lastError: null });
|
|
47
|
+
}
|
|
35
48
|
async push(namespace, topic, payload) {
|
|
36
49
|
const id = await this.client.incr(this.idKey());
|
|
37
|
-
const
|
|
38
|
-
await this.client.
|
|
50
|
+
const availableAt = Date.now();
|
|
51
|
+
await this.client.zadd(
|
|
52
|
+
this.availableKey(namespace, topic),
|
|
53
|
+
availableAt,
|
|
54
|
+
this.item(id, payload, availableAt),
|
|
55
|
+
);
|
|
39
56
|
}
|
|
40
57
|
async pushBulk(namespace, topic, payloads) {
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
await this.client.
|
|
58
|
+
if (payloads.length === 0) return;
|
|
59
|
+
const startId = await this.client.incrby(this.idKey(), payloads.length);
|
|
60
|
+
const availableAt = Date.now();
|
|
61
|
+
const values = payloads.flatMap((payload, index) => [
|
|
62
|
+
availableAt,
|
|
63
|
+
this.item(startId - payloads.length + 1 + index, payload, availableAt),
|
|
64
|
+
]);
|
|
65
|
+
await this.client.zadd(this.availableKey(namespace, topic), ...values);
|
|
49
66
|
}
|
|
50
67
|
async pop(namespace, topic, count, lockCooldownMs) {
|
|
51
68
|
const results = [];
|
|
52
|
-
const
|
|
69
|
+
const availableKey = this.availableKey(namespace, topic);
|
|
53
70
|
const lockedKey = this.lockedKey(namespace, topic);
|
|
54
71
|
const now = Date.now();
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
72
|
+
await this.client.eval(
|
|
73
|
+
REQUEUE_EXPIRED_LUA,
|
|
74
|
+
2,
|
|
75
|
+
lockedKey,
|
|
76
|
+
availableKey,
|
|
77
|
+
String(now - lockCooldownMs),
|
|
78
|
+
);
|
|
79
|
+
for (let index = 0; index < count; index++) {
|
|
80
|
+
const raw = await this.client.eval(POP_AND_LOCK_LUA, 2, availableKey, lockedKey, String(now));
|
|
59
81
|
if (!raw) break;
|
|
60
|
-
const
|
|
61
|
-
results.push(
|
|
82
|
+
const item = JSON.parse(raw);
|
|
83
|
+
results.push(item);
|
|
62
84
|
}
|
|
63
85
|
return results;
|
|
64
86
|
}
|
|
87
|
+
async findLocked(namespace, ids) {
|
|
88
|
+
const idSet = new Set(ids.map(Number));
|
|
89
|
+
const keys = await this.scanKeys(`${this.prefix}${namespace}:*:locked`);
|
|
90
|
+
const matches = [];
|
|
91
|
+
for (const key of keys) {
|
|
92
|
+
const members = await this.client.zrange(key, 0, -1);
|
|
93
|
+
for (const raw of members) {
|
|
94
|
+
const item = JSON.parse(raw);
|
|
95
|
+
if (idSet.has(Number(item.id))) matches.push({ key, raw, item });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return matches;
|
|
99
|
+
}
|
|
65
100
|
async remove(namespace, ids) {
|
|
66
101
|
if (ids.length === 0) return;
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
102
|
+
for (const { key, raw } of await this.findLocked(namespace, ids)) {
|
|
103
|
+
await this.client.zrem(key, raw);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async release(namespace, ids, { error, retryInMs = 0 } = {}) {
|
|
107
|
+
for (const { key, raw, item } of await this.findLocked(namespace, ids)) {
|
|
108
|
+
const availableKey = key.replace(/:locked$/, ':available');
|
|
109
|
+
const availableAt = Date.now() + retryInMs;
|
|
110
|
+
await this.client.zrem(key, raw);
|
|
111
|
+
await this.client.zadd(
|
|
112
|
+
availableKey,
|
|
113
|
+
availableAt,
|
|
114
|
+
JSON.stringify({ ...item, availableAt, lastError: error ?? null }),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async fail(namespace, ids, { error } = {}) {
|
|
119
|
+
for (const { key, raw, item } of await this.findLocked(namespace, ids)) {
|
|
120
|
+
const failedKey = key.replace(/:locked$/, ':failed');
|
|
121
|
+
await this.client.zrem(key, raw);
|
|
122
|
+
await this.client.hset(
|
|
123
|
+
failedKey,
|
|
124
|
+
String(item.id),
|
|
125
|
+
JSON.stringify({ ...item, status: 'failed', lastError: error ?? null }),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async retry(namespace, ids) {
|
|
130
|
+
const idSet = new Set(ids.map(Number));
|
|
131
|
+
const keys = await this.scanKeys(`${this.prefix}${namespace}:*:failed`);
|
|
132
|
+
for (const key of keys) {
|
|
133
|
+
const values = await this.client.hgetall(key);
|
|
134
|
+
for (const [id, raw] of Object.entries(values)) {
|
|
135
|
+
if (!idSet.has(Number(id))) continue;
|
|
136
|
+
const item = JSON.parse(raw);
|
|
137
|
+
await this.client.hdel(key, id);
|
|
138
|
+
const availableAt = Date.now();
|
|
139
|
+
await this.client.zadd(
|
|
140
|
+
key.replace(/:failed$/, ':available'),
|
|
141
|
+
availableAt,
|
|
142
|
+
JSON.stringify({
|
|
143
|
+
...item,
|
|
144
|
+
status: undefined,
|
|
145
|
+
attempts: 0,
|
|
146
|
+
availableAt,
|
|
147
|
+
lastError: null,
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
81
150
|
}
|
|
82
151
|
}
|
|
83
152
|
}
|
|
153
|
+
async list(namespace, topic, { status, limit = 100 } = {}) {
|
|
154
|
+
const statuses = status ? [status] : ['available', 'locked', 'failed'];
|
|
155
|
+
const rows = [];
|
|
156
|
+
for (const current of statuses) {
|
|
157
|
+
let values;
|
|
158
|
+
if (current === 'failed') {
|
|
159
|
+
values = Object.values(await this.client.hgetall(this.failedKey(namespace, topic)));
|
|
160
|
+
} else {
|
|
161
|
+
const scored = await this.client.zrange(
|
|
162
|
+
current === 'locked'
|
|
163
|
+
? this.lockedKey(namespace, topic)
|
|
164
|
+
: this.availableKey(namespace, topic),
|
|
165
|
+
0,
|
|
166
|
+
limit - 1,
|
|
167
|
+
'WITHSCORES',
|
|
168
|
+
);
|
|
169
|
+
values = [];
|
|
170
|
+
for (let index = 0; index < scored.length; index += 2) {
|
|
171
|
+
const item = JSON.parse(scored[index]);
|
|
172
|
+
values.push(
|
|
173
|
+
JSON.stringify({
|
|
174
|
+
...item,
|
|
175
|
+
availableAt: current === 'available' ? Number(scored[index + 1]) : item.availableAt,
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
rows.push(...values.map((raw) => ({ ...JSON.parse(raw), status: current })));
|
|
181
|
+
}
|
|
182
|
+
return rows.sort((a, b) => Number(a.id) - Number(b.id)).slice(0, limit);
|
|
183
|
+
}
|
|
184
|
+
async count(namespace, topic) {
|
|
185
|
+
const [available, locked, failed] = await Promise.all([
|
|
186
|
+
this.client.zcard(this.availableKey(namespace, topic)),
|
|
187
|
+
this.client.zcard(this.lockedKey(namespace, topic)),
|
|
188
|
+
this.client.hlen(this.failedKey(namespace, topic)),
|
|
189
|
+
]);
|
|
190
|
+
return { available, locked, failed };
|
|
191
|
+
}
|
|
84
192
|
async scanKeys(pattern) {
|
|
85
193
|
const keys = [];
|
|
86
194
|
let cursor = '0';
|
package/server/queue/index.js
CHANGED
|
@@ -45,7 +45,7 @@ class Queue {
|
|
|
45
45
|
this._log?.warn('Queue pop: corrupt JSON payload, using raw string', { id: r.id });
|
|
46
46
|
data = r.payload;
|
|
47
47
|
}
|
|
48
|
-
return { id: r.id, data };
|
|
48
|
+
return { id: r.id, data, attempts: r.attempts ?? 0, lastError: r.lastError ?? null };
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -53,6 +53,34 @@ class Queue {
|
|
|
53
53
|
await this._driver.remove(this._namespace, ids);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
async release(ids, options) {
|
|
57
|
+
await this._driver.release(this._namespace, ids, options);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async fail(ids, options) {
|
|
61
|
+
await this._driver.fail(this._namespace, ids, options);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async retry(ids) {
|
|
65
|
+
await this._driver.retry(this._namespace, ids);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async list(topic, options) {
|
|
69
|
+
const rows = await this._driver.list(this._namespace, topic, options);
|
|
70
|
+
return rows.map((row) => ({
|
|
71
|
+
id: row.id,
|
|
72
|
+
data: JSON.parse(row.payload),
|
|
73
|
+
status: row.status,
|
|
74
|
+
attempts: Number(row.attempts ?? 0),
|
|
75
|
+
availableAt: row.available_at ?? row.availableAt ?? null,
|
|
76
|
+
lastError: row.last_error ?? row.lastError ?? null,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async count(topic) {
|
|
81
|
+
return this._driver.count(this._namespace, topic);
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
/** Return a namespaced child that shares the same driver. */
|
|
57
85
|
withNamespace(namespace) {
|
|
58
86
|
const child = Object.create(Queue.prototype);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ErrorCodes } from '../constants.js';
|
|
2
|
+
import { resolveRateLimitKey } from '../router/ratelimit-key.js';
|
|
2
3
|
function defaultKeyFn(req) {
|
|
3
4
|
const forwarded = req.headers['x-forwarded-for'];
|
|
4
5
|
if (forwarded) return forwarded.split(',')[0].trim();
|
|
@@ -9,8 +10,9 @@ function createRateLimitMiddleware(options, store) {
|
|
|
9
10
|
const windowMs = options.windowMs ?? 60000;
|
|
10
11
|
const keyFn = options.keyFn ?? defaultKeyFn;
|
|
11
12
|
return async (ctx) => {
|
|
12
|
-
const
|
|
13
|
-
|
|
13
|
+
const resolved = resolveRateLimitKey({ _by: keyFn }, ctx.req);
|
|
14
|
+
if (!resolved) return;
|
|
15
|
+
const result = await store.check(resolved.value, max, windowMs);
|
|
14
16
|
if (!result.allowed) {
|
|
15
17
|
const resetTimestamp = Math.ceil((Date.now() + result.resetMs) / 1e3);
|
|
16
18
|
const retryAfterSec = Math.ceil(result.resetMs / 1e3);
|
|
@@ -154,7 +154,7 @@ class ApiRouter {
|
|
|
154
154
|
|
|
155
155
|
async executeRoute(route, reqInfo) {
|
|
156
156
|
if (route.config._parsedRateLimit && this._redis) {
|
|
157
|
-
const checked = await checkRouteRateLimits(this._redis.client, route.config, reqInfo
|
|
157
|
+
const checked = await checkRouteRateLimits(this._redis.client, route.config, reqInfo);
|
|
158
158
|
if (!checked.allowed) {
|
|
159
159
|
const { policy, result } = checked.selected;
|
|
160
160
|
const retryAfter = Math.max(1, result.resetAt - Math.ceil(Date.now() / 1000));
|
|
@@ -274,9 +274,16 @@ class ApiRouter {
|
|
|
274
274
|
|
|
275
275
|
// ── Rate limiting ──
|
|
276
276
|
if (route.config._parsedRateLimit && this._redis) {
|
|
277
|
-
const checked = await checkRouteRateLimits(this._redis.client, route.config,
|
|
278
|
-
|
|
277
|
+
const checked = await checkRouteRateLimits(this._redis.client, route.config, {
|
|
278
|
+
ip,
|
|
279
|
+
query: req.query ?? {},
|
|
280
|
+
params,
|
|
281
|
+
body: req.body,
|
|
282
|
+
session: req.session,
|
|
283
|
+
headers: req.flatHeaders ?? flattenHeaders(req.headers),
|
|
284
|
+
});
|
|
279
285
|
if (!checked.allowed) {
|
|
286
|
+
const { policy, result } = checked.selected;
|
|
280
287
|
const retryAfter = Math.max(1, result.resetAt - Math.ceil(Date.now() / 1000));
|
|
281
288
|
sendJson(
|
|
282
289
|
res,
|
|
@@ -296,9 +303,12 @@ class ApiRouter {
|
|
|
296
303
|
);
|
|
297
304
|
return true;
|
|
298
305
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
306
|
+
if (checked.selected) {
|
|
307
|
+
const { policy, result } = checked.selected;
|
|
308
|
+
res.setHeader('X-RateLimit-Limit', String(policy.max));
|
|
309
|
+
res.setHeader('X-RateLimit-Remaining', String(result.remaining));
|
|
310
|
+
res.setHeader('X-RateLimit-Reset', String(result.resetAt));
|
|
311
|
+
}
|
|
302
312
|
}
|
|
303
313
|
|
|
304
314
|
// ── Body parsing control ──
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const RATE_LIMIT_ROOTS = new Set(['body', 'query', 'params', 'session', 'headers']);
|
|
4
|
+
|
|
5
|
+
function hashRateLimitValue(value) {
|
|
6
|
+
return createHash('sha256').update(value.trim().toLowerCase()).digest('hex').slice(0, 32);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isValidRateLimitBy(by) {
|
|
10
|
+
if (typeof by === 'function' || by === 'ip') return true;
|
|
11
|
+
if (typeof by !== 'string') return false;
|
|
12
|
+
const [root, ...segments] = by.split('.');
|
|
13
|
+
return RATE_LIMIT_ROOTS.has(root) && segments.length > 0 && segments.every(Boolean);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readPath(req, path) {
|
|
17
|
+
const [root, ...segments] = path.split('.');
|
|
18
|
+
let value = req[root];
|
|
19
|
+
for (const segment of segments) {
|
|
20
|
+
if (value == null || typeof value !== 'object') return null;
|
|
21
|
+
value = value[segment];
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveRateLimitKey(policy, req) {
|
|
27
|
+
const by = policy._by ?? 'ip';
|
|
28
|
+
if (by === 'ip') return { scope: 'ip', value: req.ip ?? 'unknown' };
|
|
29
|
+
const raw = typeof by === 'function' ? by(req) : readPath(req, by);
|
|
30
|
+
if (raw == null) return null;
|
|
31
|
+
const value = String(raw).trim();
|
|
32
|
+
if (!value) return null;
|
|
33
|
+
return {
|
|
34
|
+
scope: typeof by === 'function' ? 'custom' : by,
|
|
35
|
+
value: hashRateLimitValue(value),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { hashRateLimitValue, isValidRateLimitBy, resolveRateLimitKey };
|
|
@@ -38,8 +38,8 @@ local ttl = redis.call('TTL', KEYS[1])
|
|
|
38
38
|
return {count, ttl}
|
|
39
39
|
`;
|
|
40
40
|
|
|
41
|
-
async function checkRateLimit(redisClient, { key, ip, max, windowSec }) {
|
|
42
|
-
const redisKey = `rl:${key}:${windowSec}:${
|
|
41
|
+
async function checkRateLimit(redisClient, { key, ip, scope = 'ip', value = ip, max, windowSec }) {
|
|
42
|
+
const redisKey = `rl:${key}:${windowSec}:${scope}:${value}`;
|
|
43
43
|
const result = await redisClient.eval(LUA_INCR_EXPIRE, 1, redisKey, windowSec);
|
|
44
44
|
const count = result[0];
|
|
45
45
|
const ttl = result[1];
|
|
@@ -57,14 +57,18 @@ function configuredPolicies(config) {
|
|
|
57
57
|
return [{ key: config.ratelimit.key, ...config._parsedRateLimit }];
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
async function checkRouteRateLimits(redisClient, config,
|
|
60
|
+
async function checkRouteRateLimits(redisClient, config, req) {
|
|
61
61
|
const policies = configuredPolicies(config);
|
|
62
|
+
const applicable = policies
|
|
63
|
+
.map((policy) => ({ policy, resolved: resolveRateLimitKey(policy, req) }))
|
|
64
|
+
.filter(({ resolved }) => resolved !== null);
|
|
62
65
|
const results = await Promise.all(
|
|
63
|
-
|
|
66
|
+
applicable.map(async ({ policy, resolved }) => ({
|
|
64
67
|
policy,
|
|
65
|
-
result: await checkRateLimit(redisClient, { ...policy,
|
|
68
|
+
result: await checkRateLimit(redisClient, { ...policy, ...resolved }),
|
|
66
69
|
})),
|
|
67
70
|
);
|
|
71
|
+
if (results.length === 0) return { allowed: true, selected: null, results };
|
|
68
72
|
const denied = results.filter(({ result }) => !result.allowed);
|
|
69
73
|
const candidates = denied.length > 0 ? denied : results;
|
|
70
74
|
const selected = candidates.reduce((strictest, candidate) => {
|
|
@@ -84,3 +88,4 @@ async function checkRouteRateLimits(redisClient, config, ip) {
|
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
export { checkRateLimit, checkRouteRateLimits, parseRateLimit };
|
|
91
|
+
import { resolveRateLimitKey } from './ratelimit-key.js';
|
package/server/router/routes.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { compileRoutePattern, extractRouteParams } from '#client/route-pattern.js';
|
|
2
2
|
import { discoverModules } from '../discovery.js';
|
|
3
3
|
import { parseRateLimit } from './ratelimit.js';
|
|
4
|
+
import { isValidRateLimitBy } from './ratelimit-key.js';
|
|
4
5
|
const HTTP_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
5
6
|
function filePathToPattern(relativePath) {
|
|
6
7
|
let route = relativePath.replace(/\\/g, '/');
|
|
@@ -84,8 +85,16 @@ function buildRoutesFromModules(entries, { prefix = '', plugin } = {}) {
|
|
|
84
85
|
`Route ${method} ${urlPattern} (${filePath}) ratelimit${suffix}.key must be a non-empty string`,
|
|
85
86
|
);
|
|
86
87
|
}
|
|
88
|
+
const by = policy.by ?? 'ip';
|
|
89
|
+
if (!isValidRateLimitBy(by)) {
|
|
90
|
+
const suffix = policies.length > 1 ? `[${index}]` : '';
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Route ${method} ${urlPattern} (${filePath}) ratelimit${suffix}.by must be "ip", a request path, or a function`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
87
95
|
return {
|
|
88
96
|
key: policy.key ?? defaultRateLimitKey(relativePath, method, prefix),
|
|
97
|
+
_by: by,
|
|
89
98
|
...parseRateLimit(policy.limit),
|
|
90
99
|
};
|
|
91
100
|
});
|
package/server/testing/index.js
CHANGED
|
@@ -170,6 +170,8 @@ async function testBoot(options) {
|
|
|
170
170
|
});
|
|
171
171
|
const baseUrl = `http://127.0.0.1:${app.port}`;
|
|
172
172
|
const appContext = {
|
|
173
|
+
config: app.config,
|
|
174
|
+
env: app.config?.env,
|
|
173
175
|
db: app.db,
|
|
174
176
|
events: app.eventBus,
|
|
175
177
|
queue: { push: async () => {}, pop: async () => [], remove: async () => {} },
|