arcway 0.1.7 → 0.1.8
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
CHANGED
package/server/events/handler.js
CHANGED
|
@@ -9,12 +9,12 @@ function listenerPathToEvent(relativePath) {
|
|
|
9
9
|
.replace(/\[\.\.\.([^\]]+)\]/g, '*');
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
function
|
|
13
|
-
|
|
14
|
-
if (typeof
|
|
15
|
-
if (typeof
|
|
12
|
+
function validateHandler(item, filePath, index) {
|
|
13
|
+
const label = index !== undefined ? `[${index}]` : '';
|
|
14
|
+
if (typeof item === 'function') return item;
|
|
15
|
+
if (typeof item === 'object' && item !== null && typeof item.handler === 'function') return item.handler;
|
|
16
16
|
throw new Error(
|
|
17
|
-
`Listener at ${filePath} must
|
|
17
|
+
`Listener at ${filePath}${label} must be a function or an object with a "handler" function`,
|
|
18
18
|
);
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -47,15 +47,22 @@ class EventHandler {
|
|
|
47
47
|
label: 'listener file',
|
|
48
48
|
});
|
|
49
49
|
for (const { filePath, relativePath, module } of entries) {
|
|
50
|
-
const
|
|
51
|
-
|
|
50
|
+
const exported = module.default;
|
|
51
|
+
if (!exported) throw new Error(`Listener file at ${filePath} must have a default export`);
|
|
52
|
+
|
|
53
|
+
const event = listenerPathToEvent(relativePath);
|
|
52
54
|
if (event.startsWith('system/') || event === 'system') continue;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
|
|
56
|
+
const items = Array.isArray(exported) ? exported : [exported];
|
|
57
|
+
for (let i = 0; i < items.length; i++) {
|
|
58
|
+
const handler = validateHandler(items[i], filePath, items.length > 1 ? i : undefined);
|
|
59
|
+
this._events.subscribe(event, (_eventName, payload) => {
|
|
60
|
+
const ctx = buildContext(this._appContext, { event: { name: _eventName, payload } });
|
|
61
|
+
handler(ctx);
|
|
62
|
+
});
|
|
63
|
+
this._listeners.push({ event, fileName: relativePath });
|
|
64
|
+
}
|
|
65
|
+
this._log?.info(` ${relativePath} → ${event}${items.length > 1 ? ` (${items.length} listeners)` : ''}`);
|
|
59
66
|
}
|
|
60
67
|
}
|
|
61
68
|
|
|
@@ -2,11 +2,12 @@ import { buildContext } from '../../context.js';
|
|
|
2
2
|
import { validateEnqueue, toError, calculateBackoff } from '../queue.js';
|
|
3
3
|
import { checkDbThroughput } from '../throughput.js';
|
|
4
4
|
import LeaseManager from './lease.js';
|
|
5
|
-
const
|
|
5
|
+
const DEFAULT_STALE_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
6
6
|
class KnexJobQueue {
|
|
7
7
|
db;
|
|
8
8
|
tableName;
|
|
9
9
|
backoffMs;
|
|
10
|
+
staleTimeoutMs;
|
|
10
11
|
registered = new Map();
|
|
11
12
|
_size = 0;
|
|
12
13
|
leaseManager;
|
|
@@ -14,6 +15,7 @@ class KnexJobQueue {
|
|
|
14
15
|
this.db = db;
|
|
15
16
|
this.tableName = options?.tableName ?? 'arcway_jobs';
|
|
16
17
|
this.backoffMs = options?.backoffMs ?? 1000;
|
|
18
|
+
this.staleTimeoutMs = options?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS;
|
|
17
19
|
this.leaseManager = new LeaseManager(db, { tableName: options?.leaseTableName });
|
|
18
20
|
}
|
|
19
21
|
/** Create the jobs table if it doesn't exist. Must be called before use. */
|
|
@@ -74,10 +76,24 @@ class KnexJobQueue {
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
async _recoverStaleJobs(now) {
|
|
77
|
-
const
|
|
78
|
-
await this.db(this.tableName)
|
|
79
|
+
const runningJobs = await this.db(this.tableName)
|
|
79
80
|
.where('status', 'running')
|
|
80
|
-
.
|
|
81
|
+
.select('id', 'qualified_name', 'updated_at');
|
|
82
|
+
if (runningJobs.length === 0) return;
|
|
83
|
+
|
|
84
|
+
const staleIds = [];
|
|
85
|
+
for (const row of runningJobs) {
|
|
86
|
+
const reg = this.registered.get(row.qualified_name);
|
|
87
|
+
const timeout = reg?.definition.staleTimeout ?? this.staleTimeoutMs;
|
|
88
|
+
const updatedAt = new Date(row.updated_at).getTime();
|
|
89
|
+
if (now - updatedAt > timeout) {
|
|
90
|
+
staleIds.push(row.id);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (staleIds.length === 0) return;
|
|
94
|
+
|
|
95
|
+
await this.db(this.tableName)
|
|
96
|
+
.whereIn('id', staleIds)
|
|
81
97
|
.update({ status: 'pending', updated_at: new Date().toISOString() });
|
|
82
98
|
}
|
|
83
99
|
|
package/server/jobs/runner.js
CHANGED
|
@@ -9,18 +9,19 @@ import { SYSTEM_JOB_DOMAIN, registerSystemJobs } from '../system-jobs/index.js';
|
|
|
9
9
|
async function discoverJobs(jobsDir) {
|
|
10
10
|
const entries = await discoverModules(jobsDir, { recursive: true, label: 'job file' });
|
|
11
11
|
const jobs = [];
|
|
12
|
-
for (const { name, filePath, module } of entries) {
|
|
12
|
+
for (const { name, filePath, relativePath, module } of entries) {
|
|
13
13
|
const job = module.default;
|
|
14
14
|
if (!job || typeof job !== 'object') {
|
|
15
15
|
throw new Error(`Job file at ${filePath} must have a default export`);
|
|
16
16
|
}
|
|
17
|
-
if (!job.name || typeof job.name !== 'string') {
|
|
18
|
-
throw new Error(`Job at ${filePath} must export a "name" string`);
|
|
19
|
-
}
|
|
20
17
|
if (typeof job.handler !== 'function') {
|
|
21
18
|
throw new Error(`Job at ${filePath} must export a "handler" function`);
|
|
22
19
|
}
|
|
23
|
-
|
|
20
|
+
// Default job name from file path: billing/send-invoice.js → billing/send-invoice
|
|
21
|
+
if (!job.name) {
|
|
22
|
+
job.name = relativePath.replace(/\\/g, '/').replace(/\.js$/, '');
|
|
23
|
+
}
|
|
24
|
+
jobs.push({ definition: job, fileName: name, cooldownMs: job.cooldownMs, staleTimeout: job.staleTimeout });
|
|
24
25
|
}
|
|
25
26
|
return jobs;
|
|
26
27
|
}
|
|
@@ -41,7 +42,7 @@ class JobRunner {
|
|
|
41
42
|
constructor(config, { db, queue, cache, files, mail, events, log } = {}) {
|
|
42
43
|
this._config = config;
|
|
43
44
|
this._log = log;
|
|
44
|
-
this._dispatcher = new JobDispatcher({ backoffMs: config?.backoffMs });
|
|
45
|
+
this._dispatcher = new JobDispatcher({ backoffMs: config?.backoffMs, staleTimeoutMs: config?.staleTimeoutMs });
|
|
45
46
|
this._appContext = { db, queue, cache, files, mail, events, log };
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -70,6 +71,8 @@ class JobRunner {
|
|
|
70
71
|
fileName,
|
|
71
72
|
schedule: definition.schedule,
|
|
72
73
|
handler: definition.handler,
|
|
74
|
+
cooldownMs: definition.cooldownMs,
|
|
75
|
+
staleTimeout: definition.staleTimeout,
|
|
73
76
|
});
|
|
74
77
|
this._log?.info(` ${fileName} \u2192 ${definition.name}`);
|
|
75
78
|
}
|
|
@@ -95,6 +98,7 @@ class JobRunner {
|
|
|
95
98
|
this._continuousJobs.push({
|
|
96
99
|
qualifiedName: `app/${job.jobName}`,
|
|
97
100
|
handler: job.handler,
|
|
101
|
+
cooldownMs: job.cooldownMs,
|
|
98
102
|
appContext: this._appContext,
|
|
99
103
|
});
|
|
100
104
|
} else if (job.schedule) {
|
|
@@ -169,13 +173,23 @@ class JobRunner {
|
|
|
169
173
|
|
|
170
174
|
async _runContinuousLoop(entry) {
|
|
171
175
|
const backoffMs = this._config?.backoffMs ?? 1000;
|
|
176
|
+
const cooldownMs = entry.cooldownMs ?? this._config?.cooldownMs ?? 1000;
|
|
172
177
|
let consecutiveErrors = 0;
|
|
173
178
|
console.log(`[job-runner] continuous: ${entry.qualifiedName} started`);
|
|
174
179
|
while (!this._stopped) {
|
|
175
180
|
try {
|
|
181
|
+
const start = Date.now();
|
|
176
182
|
const ctx = buildContext(entry.appContext, { payload: void 0 });
|
|
177
183
|
await entry.handler(ctx);
|
|
178
184
|
consecutiveErrors = 0;
|
|
185
|
+
const elapsed = Date.now() - start;
|
|
186
|
+
if (cooldownMs > 0 && elapsed < cooldownMs) {
|
|
187
|
+
const remaining = cooldownMs - elapsed;
|
|
188
|
+
const deadline = Date.now() + remaining;
|
|
189
|
+
while (!this._stopped && Date.now() < deadline) {
|
|
190
|
+
await new Promise((r) => setTimeout(r, Math.min(100, deadline - Date.now())));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
179
193
|
} catch (err) {
|
|
180
194
|
consecutiveErrors++;
|
|
181
195
|
const error = toError(err);
|
|
@@ -116,7 +116,7 @@ class ApiRouter {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
async executeRoute(route, reqInfo) {
|
|
119
|
-
const middlewareFns = getMiddlewareForRoute(this._middleware, route.pattern);
|
|
119
|
+
const middlewareFns = getMiddlewareForRoute(this._middleware, route.pattern, route.method);
|
|
120
120
|
const chainedHandler = buildMiddlewareChain(middlewareFns, route.config.handler);
|
|
121
121
|
const ctx = this._buildCtx(reqInfo);
|
|
122
122
|
try {
|
|
@@ -225,7 +225,7 @@ class ApiRouter {
|
|
|
225
225
|
};
|
|
226
226
|
|
|
227
227
|
// ── Middleware + handler ──
|
|
228
|
-
const middlewareFns = getMiddlewareForRoute(this._middleware, route.pattern);
|
|
228
|
+
const middlewareFns = getMiddlewareForRoute(this._middleware, route.pattern, method);
|
|
229
229
|
const middlewareNames = middlewareFns.map((mw) => mw.name || 'anonymous');
|
|
230
230
|
const chainedHandler = buildMiddlewareChain(middlewareFns, route.config.handler);
|
|
231
231
|
const ctx = this._buildCtx(reqInfo);
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { discoverModules } from '../discovery.js';
|
|
3
3
|
import { validateRequestSchema } from '../validation.js';
|
|
4
|
+
|
|
5
|
+
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
6
|
+
|
|
4
7
|
function isMiddlewareConfig(value) {
|
|
5
8
|
return (
|
|
6
9
|
typeof value === 'object' &&
|
|
@@ -22,6 +25,23 @@ function wrapMiddlewareConfig(config) {
|
|
|
22
25
|
return handler(ctx);
|
|
23
26
|
};
|
|
24
27
|
}
|
|
28
|
+
|
|
29
|
+
function parseMiddlewareItems(exported, filePath, label) {
|
|
30
|
+
const items = Array.isArray(exported) ? exported : [exported];
|
|
31
|
+
const fns = [];
|
|
32
|
+
for (let i = 0; i < items.length; i++) {
|
|
33
|
+
const item = items[i];
|
|
34
|
+
if (isMiddlewareConfig(item)) {
|
|
35
|
+
fns.push(wrapMiddlewareConfig(item));
|
|
36
|
+
} else {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Middleware file "${filePath}" ${label}${items.length > 1 ? `[${i}]` : ''} must be a MiddlewareConfig object with a handler function. Got ${typeof item}.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return fns;
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
async function discoverMiddleware(apiDir) {
|
|
26
46
|
const middleware = [];
|
|
27
47
|
const entries = await discoverModules(apiDir, {
|
|
@@ -38,38 +58,51 @@ async function discoverMiddleware(apiDir) {
|
|
|
38
58
|
const converted = dirParts.map((p) => p.replace(/\[([^\]]+)\]/g, ':$1'));
|
|
39
59
|
pathPrefix = '/' + converted.join('/');
|
|
40
60
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
|
|
62
|
+
const hasDefault = module.default != null;
|
|
63
|
+
const hasMethodExports = HTTP_METHODS.some((m) => module[m] != null);
|
|
64
|
+
|
|
65
|
+
if (!hasDefault && !hasMethodExports) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Middleware file "${filePath}" must have a default export or method-specific exports (GET, POST, etc.)`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const entry = { pathPrefix, fns: [] };
|
|
72
|
+
|
|
73
|
+
if (hasDefault) {
|
|
74
|
+
entry.fns = parseMiddlewareItems(module.default, filePath, 'default export');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (hasMethodExports) {
|
|
78
|
+
entry.methodFns = {};
|
|
79
|
+
for (const method of HTTP_METHODS) {
|
|
80
|
+
if (module[method] != null) {
|
|
81
|
+
entry.methodFns[method] = parseMiddlewareItems(module[method], filePath, `${method} export`);
|
|
58
82
|
}
|
|
59
83
|
}
|
|
60
|
-
middleware.push({ pathPrefix, fns });
|
|
61
84
|
}
|
|
85
|
+
|
|
86
|
+
middleware.push(entry);
|
|
62
87
|
}
|
|
63
88
|
middleware.sort((a, b) => a.pathPrefix.length - b.pathPrefix.length);
|
|
64
89
|
return middleware;
|
|
65
90
|
}
|
|
66
|
-
function getMiddlewareForRoute(allMiddleware, routePattern) {
|
|
91
|
+
function getMiddlewareForRoute(allMiddleware, routePattern, method) {
|
|
67
92
|
const matching = [];
|
|
93
|
+
const upperMethod = method?.toUpperCase();
|
|
68
94
|
for (const mw of allMiddleware) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
95
|
+
const pathMatches = mw.pathPrefix === '/' ||
|
|
96
|
+
routePattern === mw.pathPrefix ||
|
|
97
|
+
routePattern.startsWith(mw.pathPrefix + '/');
|
|
98
|
+
if (!pathMatches) continue;
|
|
99
|
+
|
|
100
|
+
// Add default (all-method) middleware
|
|
101
|
+
matching.push(...mw.fns);
|
|
102
|
+
|
|
103
|
+
// Add method-specific middleware
|
|
104
|
+
if (upperMethod && mw.methodFns?.[upperMethod]) {
|
|
105
|
+
matching.push(...mw.methodFns[upperMethod]);
|
|
73
106
|
}
|
|
74
107
|
}
|
|
75
108
|
return matching;
|
package/server/ws/realtime.js
CHANGED
|
@@ -82,7 +82,7 @@ function createRealtimeServer(options) {
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
async function runHandler(route, reqInfo) {
|
|
85
|
-
const middlewareFns = getMiddlewareForRoute(middleware, route.pattern);
|
|
85
|
+
const middlewareFns = getMiddlewareForRoute(middleware, route.pattern, 'GET');
|
|
86
86
|
const chainedHandler = buildMiddlewareChain(middlewareFns, route.config.handler);
|
|
87
87
|
const ctx = buildCtx(reqInfo);
|
|
88
88
|
try {
|