@yunsoft/yuncms-api 0.1.5 → 0.1.7
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 +9 -3
- package/src/app.js +93 -50
- package/src/error-response.js +1 -0
- package/src/extensions/runtime.js +44 -9
- package/src/extensions/scheduler.js +288 -0
- package/src/external-auth/config.js +204 -0
- package/src/external-auth/providers.js +440 -0
- package/src/mcp.js +364 -0
- package/src/pressure-limit.js +7 -2
- package/src/rate-limit.js +52 -22
- package/src/routes/auth.js +132 -55
- package/src/routes/schema.js +46 -9
- package/src/server.js +75 -52
- package/studio-dist/assets/index-B30LRjIx.js +9 -0
- package/studio-dist/index.html +1 -1
- package/studio-dist/assets/index-CT3jNSGp.js +0 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yunsoft/yuncms-api",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Express API runtime and bundled Studio server for YunCMS.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,7 +32,13 @@
|
|
|
32
32
|
"test": "node --test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@
|
|
36
|
-
"
|
|
35
|
+
"@modelcontextprotocol/node": "2.0.0",
|
|
36
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
37
|
+
"@node-saml/node-saml": "5.1.0",
|
|
38
|
+
"@yunsoft/yuncms-core": "0.1.7",
|
|
39
|
+
"express": "5.2.1",
|
|
40
|
+
"ldapts": "9.0.0",
|
|
41
|
+
"openid-client": "6.8.7",
|
|
42
|
+
"zod": "4.4.3"
|
|
37
43
|
}
|
|
38
44
|
}
|
package/src/app.js
CHANGED
|
@@ -23,19 +23,10 @@ import { createStudioMiddleware } from './studio.js';
|
|
|
23
23
|
|
|
24
24
|
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
|
|
25
25
|
const CONTENT_SECURITY_POLICY = [
|
|
26
|
-
"default-src 'self'",
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"script-src 'self'",
|
|
31
|
-
"style-src 'self' 'unsafe-inline'",
|
|
32
|
-
"img-src 'self' data: blob: https://yunsoft.com",
|
|
33
|
-
"font-src 'self' data:",
|
|
34
|
-
"connect-src 'self'",
|
|
35
|
-
"media-src 'self' blob:",
|
|
36
|
-
"frame-src 'self' blob:",
|
|
37
|
-
"worker-src 'self' blob:",
|
|
38
|
-
"form-action 'self'",
|
|
26
|
+
"default-src 'self'", "base-uri 'self'", "object-src 'none'", "frame-ancestors 'none'",
|
|
27
|
+
"script-src 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob: https://yunsoft.com",
|
|
28
|
+
"font-src 'self' data:", "connect-src 'self'", "media-src 'self' blob:", "frame-src 'self' blob:",
|
|
29
|
+
"worker-src 'self' blob:", "form-action 'self'",
|
|
39
30
|
].join('; ');
|
|
40
31
|
|
|
41
32
|
function securityHeaders(req, res, next) {
|
|
@@ -47,9 +38,7 @@ function securityHeaders(req, res, next) {
|
|
|
47
38
|
res.set('cross-origin-opener-policy', 'same-origin');
|
|
48
39
|
res.set('x-dns-prefetch-control', 'off');
|
|
49
40
|
res.set('content-security-policy', CONTENT_SECURITY_POLICY);
|
|
50
|
-
if (req.secure === true)
|
|
51
|
-
res.set('strict-transport-security', 'max-age=15552000; includeSubDomains');
|
|
52
|
-
}
|
|
41
|
+
if (req.secure === true) res.set('strict-transport-security', 'max-age=15552000; includeSubDomains');
|
|
53
42
|
next();
|
|
54
43
|
}
|
|
55
44
|
|
|
@@ -57,14 +46,12 @@ function studioCors(config) {
|
|
|
57
46
|
return (req, res, next) => {
|
|
58
47
|
const origin = req.get('origin');
|
|
59
48
|
const allowedOrigin = config?.server?.studioOrigin;
|
|
60
|
-
|
|
61
49
|
if (origin && allowedOrigin && origin === allowedOrigin) {
|
|
62
50
|
res.set('access-control-allow-origin', origin);
|
|
63
51
|
res.set('vary', 'Origin');
|
|
64
|
-
res.set('access-control-allow-headers', 'content-type, authorization, x-request-id, x-filename, x-title, x-mimetype');
|
|
52
|
+
res.set('access-control-allow-headers', 'content-type, authorization, x-request-id, x-filename, x-title, x-mimetype, mcp-protocol-version, mcp-method, mcp-name');
|
|
65
53
|
res.set('access-control-allow-methods', 'GET,POST,PATCH,DELETE,OPTIONS');
|
|
66
54
|
}
|
|
67
|
-
|
|
68
55
|
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
|
69
56
|
return next();
|
|
70
57
|
};
|
|
@@ -77,16 +64,55 @@ function requestIdentity(req, res, next) {
|
|
|
77
64
|
next();
|
|
78
65
|
}
|
|
79
66
|
|
|
80
|
-
function createApiRateLimit(config) {
|
|
67
|
+
function createApiRateLimit(config, rateLimitStore = null, logger = console) {
|
|
81
68
|
const limits = config?.server?.rateLimit;
|
|
82
69
|
if (!limits?.enabled) return null;
|
|
83
70
|
return createFixedWindowRateLimit({
|
|
84
71
|
windowMs: limits.windowMs,
|
|
85
72
|
max: limits.max,
|
|
86
73
|
maxBuckets: limits.maxBuckets,
|
|
74
|
+
store: limits.store === 'redis' ? rateLimitStore : null,
|
|
75
|
+
scope: 'api',
|
|
76
|
+
failureMode: limits.failureMode ?? 'best-effort',
|
|
77
|
+
logger,
|
|
87
78
|
});
|
|
88
79
|
}
|
|
89
80
|
|
|
81
|
+
function requestEventMetadata(req, res, startedAt) {
|
|
82
|
+
return {
|
|
83
|
+
requestId: req.id ?? null,
|
|
84
|
+
method: req.method,
|
|
85
|
+
route: req.route?.path ?? req.path,
|
|
86
|
+
status: res.statusCode,
|
|
87
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
88
|
+
accountability: req.accountability ? {
|
|
89
|
+
user: req.accountability.user ?? null,
|
|
90
|
+
role: req.accountability.role ?? null,
|
|
91
|
+
admin: req.accountability.admin === true,
|
|
92
|
+
system: req.accountability.system === true,
|
|
93
|
+
} : null,
|
|
94
|
+
ip: req.ip ?? null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function createRequestEvents(emitter) {
|
|
99
|
+
if (!emitter) return null;
|
|
100
|
+
return (req, res, next) => {
|
|
101
|
+
const startedAt = Date.now();
|
|
102
|
+
emitter.action('request.received', requestEventMetadata(req, res, startedAt), {
|
|
103
|
+
accountability: req.accountability,
|
|
104
|
+
requestId: req.id,
|
|
105
|
+
}).catch(() => {});
|
|
106
|
+
res.once('finish', () => {
|
|
107
|
+
emitter.action('request.completed', requestEventMetadata(req, res, startedAt), {
|
|
108
|
+
accountability: req.accountability,
|
|
109
|
+
requestId: req.id,
|
|
110
|
+
}).catch(() => {});
|
|
111
|
+
});
|
|
112
|
+
next();
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
90
116
|
export function createApp({
|
|
91
117
|
pool,
|
|
92
118
|
config,
|
|
@@ -97,7 +123,11 @@ export function createApp({
|
|
|
97
123
|
emitter = null,
|
|
98
124
|
storage = null,
|
|
99
125
|
mailer = null,
|
|
126
|
+
rateLimitStore = null,
|
|
127
|
+
redisClient = null,
|
|
128
|
+
externalAuthRegistry = null,
|
|
100
129
|
endpointExtensions = [],
|
|
130
|
+
mcpRouter = null,
|
|
101
131
|
studioRoot = undefined,
|
|
102
132
|
}) {
|
|
103
133
|
if (!pool) throw new Error('Database pool is required');
|
|
@@ -118,40 +148,48 @@ export function createApp({
|
|
|
118
148
|
});
|
|
119
149
|
|
|
120
150
|
app.get('/ready', async (req, res) => {
|
|
151
|
+
const failures = [];
|
|
121
152
|
try {
|
|
122
153
|
const ready = await pingDatabase(pool);
|
|
123
|
-
if (!ready)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
logger.warn?.('YunCMS readiness check failed', { requestId: req.id, error });
|
|
127
|
-
res.status(503).json({
|
|
128
|
-
status: 'not_ready',
|
|
129
|
-
request_id: req.id,
|
|
130
|
-
errors: [{ code: 'DATABASE_UNAVAILABLE', message: 'Database is not ready' }],
|
|
131
|
-
});
|
|
154
|
+
if (!ready) failures.push({ code: 'DATABASE_UNAVAILABLE', message: 'Database is not ready' });
|
|
155
|
+
} catch {
|
|
156
|
+
failures.push({ code: 'DATABASE_UNAVAILABLE', message: 'Database is not ready' });
|
|
132
157
|
}
|
|
158
|
+
if (config.redis?.required && redisClient) {
|
|
159
|
+
try {
|
|
160
|
+
if (!await redisClient.ping()) throw new Error('Unexpected Redis ping result');
|
|
161
|
+
} catch {
|
|
162
|
+
failures.push({ code: 'SHARED_STATE_UNAVAILABLE', message: 'Required shared state is not ready' });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (failures.length) {
|
|
166
|
+
logger.warn?.('YunCMS readiness check failed', { requestId: req.id, failures: failures.map((entry) => entry.code) });
|
|
167
|
+
return res.status(503).json({ status: 'not_ready', request_id: req.id, errors: failures });
|
|
168
|
+
}
|
|
169
|
+
return res.json({
|
|
170
|
+
status: 'ready',
|
|
171
|
+
request_id: req.id,
|
|
172
|
+
shared_state: {
|
|
173
|
+
cache: config.cache?.store ?? 'memory',
|
|
174
|
+
api_rate_limit: config.server?.rateLimit?.store ?? 'memory',
|
|
175
|
+
auth_rate_limit: config.auth?.rateLimit?.store ?? 'memory',
|
|
176
|
+
},
|
|
177
|
+
});
|
|
133
178
|
});
|
|
134
179
|
|
|
135
180
|
app.use(createStudioMiddleware({ root: studioRoot }));
|
|
136
|
-
|
|
137
181
|
const pressureLimit = createPressureLimit(config.server?.pressure);
|
|
138
182
|
if (pressureLimit) app.use(pressureLimit);
|
|
139
|
-
|
|
140
|
-
const apiRateLimit = createApiRateLimit(config);
|
|
183
|
+
const apiRateLimit = createApiRateLimit(config, rateLimitStore, logger);
|
|
141
184
|
if (apiRateLimit) app.use(apiRateLimit);
|
|
142
185
|
|
|
143
|
-
app.use(createAuthenticationMiddleware({
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
schemaCache,
|
|
149
|
-
permissionCache,
|
|
150
|
-
emitter,
|
|
151
|
-
storage,
|
|
152
|
-
}));
|
|
186
|
+
app.use(createAuthenticationMiddleware({ pool, config, logger, services, schemaCache, permissionCache, emitter, storage }));
|
|
187
|
+
const requestEvents = createRequestEvents(emitter);
|
|
188
|
+
if (requestEvents) app.use(requestEvents);
|
|
189
|
+
|
|
190
|
+
if (mcpRouter) app.use('/mcp', mcpRouter);
|
|
153
191
|
app.use('/studio-settings', createStudioSettingsRouter());
|
|
154
|
-
app.use('/auth', createAuthRouter({ mailer, config, logger }));
|
|
192
|
+
app.use('/auth', createAuthRouter({ mailer, config, logger, rateLimitStore, externalAuthRegistry }));
|
|
155
193
|
app.use('/items', createItemsRouter());
|
|
156
194
|
app.use('/schema', createSystemSchemaRouter({ schemaCache }));
|
|
157
195
|
app.use('/schema', createSchemaRouter({ schemaCache }));
|
|
@@ -162,18 +200,22 @@ export function createApp({
|
|
|
162
200
|
app.use('/audit', createAuditRouter());
|
|
163
201
|
|
|
164
202
|
for (const extension of endpointExtensions) {
|
|
165
|
-
if (!extension?.id || !extension?.router)
|
|
166
|
-
throw new Error('Invalid endpoint extension runtime entry');
|
|
167
|
-
}
|
|
203
|
+
if (!extension?.id || !extension?.router) throw new Error('Invalid endpoint extension runtime entry');
|
|
168
204
|
app.use(`/extensions/${encodeURIComponent(extension.id)}`, extension.router);
|
|
169
205
|
}
|
|
170
206
|
|
|
171
207
|
app.use((req, res) => {
|
|
172
|
-
res.status(404).json({
|
|
173
|
-
|
|
174
|
-
|
|
208
|
+
res.status(404).json({ errors: [{ code: 'NOT_FOUND', message: 'Route not found', request_id: req.id }] });
|
|
209
|
+
});
|
|
210
|
+
app.use((error, req, res, next) => {
|
|
211
|
+
if (emitter) {
|
|
212
|
+
emitter.action('request.failed', {
|
|
213
|
+
...requestEventMetadata(req, res, Date.now()),
|
|
214
|
+
error: { code: error?.code ?? 'INTERNAL_ERROR' },
|
|
215
|
+
}, { accountability: req.accountability, requestId: req.id }).catch(() => {});
|
|
216
|
+
}
|
|
217
|
+
next(error);
|
|
175
218
|
});
|
|
176
|
-
|
|
177
219
|
app.use(apiErrorHandler(logger));
|
|
178
220
|
return app;
|
|
179
221
|
}
|
|
@@ -181,6 +223,7 @@ export function createApp({
|
|
|
181
223
|
export {
|
|
182
224
|
CONTENT_SECURITY_POLICY,
|
|
183
225
|
createApiRateLimit,
|
|
226
|
+
createRequestEvents,
|
|
184
227
|
requestIdentity,
|
|
185
228
|
securityHeaders,
|
|
186
229
|
studioCors,
|
package/src/error-response.js
CHANGED
|
@@ -41,6 +41,7 @@ const STATUS_BY_CODE = new Map([
|
|
|
41
41
|
['INVALID_ON_DELETE', 400],
|
|
42
42
|
['INVALID_STORAGE_KEY', 400],
|
|
43
43
|
['INVALID_FILE_CONTENT', 400],
|
|
44
|
+
['FILE_MIME_MISMATCH', 400],
|
|
44
45
|
['INVALID_MAIL_MESSAGE', 400],
|
|
45
46
|
['STORAGE_NOT_FOUND', 400],
|
|
46
47
|
['STORAGE_INVENTORY_UNSUPPORTED', 400],
|
|
@@ -2,6 +2,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
2
2
|
import express from 'express';
|
|
3
3
|
|
|
4
4
|
import { discoverExtensions } from './discovery.js';
|
|
5
|
+
import { ExtensionScheduler } from './scheduler.js';
|
|
5
6
|
|
|
6
7
|
function extensionError(code, message) {
|
|
7
8
|
const error = new Error(message);
|
|
@@ -54,19 +55,39 @@ function createBaseContext({ services, database, schemaCache, emitter, storage,
|
|
|
54
55
|
});
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
function hookRegistrationApi(emitter, baseContext) {
|
|
58
|
+
function hookRegistrationApi(emitter, baseContext, manifest, scheduler) {
|
|
59
|
+
const registration = Object.freeze({
|
|
60
|
+
extensionId: manifest.id,
|
|
61
|
+
priority: Number.isInteger(manifest.priority) ? manifest.priority : 0,
|
|
62
|
+
});
|
|
63
|
+
|
|
58
64
|
return Object.freeze({
|
|
59
|
-
filter(event, handler) {
|
|
65
|
+
filter(event, handler, options = {}) {
|
|
60
66
|
return emitter.registerFilter(event, (payload, eventContext) =>
|
|
61
|
-
handler(payload, { ...baseContext, ...eventContext })
|
|
67
|
+
handler(payload, { ...baseContext, ...eventContext }), {
|
|
68
|
+
...registration,
|
|
69
|
+
...options,
|
|
70
|
+
extensionId: manifest.id,
|
|
71
|
+
});
|
|
62
72
|
},
|
|
63
|
-
action(event, handler) {
|
|
73
|
+
action(event, handler, options = {}) {
|
|
64
74
|
return emitter.registerAction(event, (payload, eventContext) =>
|
|
65
|
-
handler(payload, { ...baseContext, ...eventContext })
|
|
75
|
+
handler(payload, { ...baseContext, ...eventContext }), {
|
|
76
|
+
...registration,
|
|
77
|
+
...options,
|
|
78
|
+
extensionId: manifest.id,
|
|
79
|
+
});
|
|
66
80
|
},
|
|
67
|
-
init(event, handler) {
|
|
81
|
+
init(event, handler, options = {}) {
|
|
68
82
|
return emitter.registerInit(event, (eventContext) =>
|
|
69
|
-
handler({ ...baseContext, ...eventContext })
|
|
83
|
+
handler({ ...baseContext, ...eventContext }), {
|
|
84
|
+
...registration,
|
|
85
|
+
...options,
|
|
86
|
+
extensionId: manifest.id,
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
schedule(expression, handler, options = {}) {
|
|
90
|
+
return scheduler.register(manifest.id, expression, handler, options);
|
|
70
91
|
},
|
|
71
92
|
});
|
|
72
93
|
}
|
|
@@ -89,14 +110,22 @@ export async function loadExtensionRuntime({
|
|
|
89
110
|
|
|
90
111
|
const manifests = await discoverExtensions({ rootDir, localDirectory, includeDependencies });
|
|
91
112
|
const baseContext = createBaseContext({ services, database, schemaCache, emitter, storage, logger, env });
|
|
113
|
+
const scheduler = new ExtensionScheduler({
|
|
114
|
+
database,
|
|
115
|
+
services,
|
|
116
|
+
schemaCache,
|
|
117
|
+
emitter,
|
|
118
|
+
storage,
|
|
119
|
+
logger,
|
|
120
|
+
env,
|
|
121
|
+
});
|
|
92
122
|
const endpointExtensions = [];
|
|
93
|
-
const hookApi = hookRegistrationApi(emitter, baseContext);
|
|
94
123
|
|
|
95
124
|
for (const manifest of manifests) {
|
|
96
125
|
const definition = await importExtension(manifest);
|
|
97
126
|
|
|
98
127
|
if (manifest.type === 'hook') {
|
|
99
|
-
await definition.register(
|
|
128
|
+
await definition.register(hookRegistrationApi(emitter, baseContext, manifest, scheduler), baseContext);
|
|
100
129
|
logger.info?.(`Loaded YunCMS hook extension: ${manifest.id}`);
|
|
101
130
|
continue;
|
|
102
131
|
}
|
|
@@ -117,5 +146,11 @@ export async function loadExtensionRuntime({
|
|
|
117
146
|
async init(event) {
|
|
118
147
|
await emitter.init(event, baseContext);
|
|
119
148
|
},
|
|
149
|
+
startSchedules() {
|
|
150
|
+
scheduler.start();
|
|
151
|
+
},
|
|
152
|
+
async stopSchedules(options = {}) {
|
|
153
|
+
return scheduler.stop(options);
|
|
154
|
+
},
|
|
120
155
|
});
|
|
121
156
|
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
createSystemAccountability,
|
|
4
|
+
withAdvisoryLock,
|
|
5
|
+
} from '@yunsoft/yuncms-core';
|
|
6
|
+
|
|
7
|
+
const CRON_FIELD_RULES = Object.freeze([
|
|
8
|
+
{ name: 'minute', min: 0, max: 59 },
|
|
9
|
+
{ name: 'hour', min: 0, max: 23 },
|
|
10
|
+
{ name: 'day', min: 1, max: 31 },
|
|
11
|
+
{ name: 'month', min: 1, max: 12 },
|
|
12
|
+
{ name: 'weekday', min: 0, max: 6 },
|
|
13
|
+
]);
|
|
14
|
+
const JOB_ID_PATTERN = /^[A-Za-z0-9._:-]{1,100}$/;
|
|
15
|
+
|
|
16
|
+
function scheduleError(message) {
|
|
17
|
+
const error = new Error(message);
|
|
18
|
+
error.code = 'INVALID_EXTENSION_SCHEDULE';
|
|
19
|
+
return error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function integerToken(value, rule, label) {
|
|
23
|
+
if (!/^\d+$/.test(value)) throw scheduleError(`Invalid ${label} cron token: ${value}`);
|
|
24
|
+
const number = Number(value);
|
|
25
|
+
if (!Number.isInteger(number) || number < rule.min || number > rule.max) {
|
|
26
|
+
throw scheduleError(`${label} must be between ${rule.min} and ${rule.max}`);
|
|
27
|
+
}
|
|
28
|
+
return number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rangeValues(start, end, step, rule, label) {
|
|
32
|
+
if (start > end) throw scheduleError(`Invalid ${label} cron range: ${start}-${end}`);
|
|
33
|
+
const values = [];
|
|
34
|
+
for (let value = start; value <= end; value += step) values.push(value);
|
|
35
|
+
return values;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseCronPart(part, rule) {
|
|
39
|
+
const [base, rawStep, extra] = part.split('/');
|
|
40
|
+
if (extra !== undefined) throw scheduleError(`Invalid ${rule.name} cron token: ${part}`);
|
|
41
|
+
const step = rawStep === undefined ? 1 : integerToken(rawStep, { min: 1, max: rule.max - rule.min + 1 }, `${rule.name} step`);
|
|
42
|
+
|
|
43
|
+
if (base === '*') return rangeValues(rule.min, rule.max, step, rule, rule.name);
|
|
44
|
+
if (base.includes('-')) {
|
|
45
|
+
const bits = base.split('-');
|
|
46
|
+
if (bits.length !== 2) throw scheduleError(`Invalid ${rule.name} cron range: ${base}`);
|
|
47
|
+
return rangeValues(
|
|
48
|
+
integerToken(bits[0], rule, rule.name),
|
|
49
|
+
integerToken(bits[1], rule, rule.name),
|
|
50
|
+
step,
|
|
51
|
+
rule,
|
|
52
|
+
rule.name,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (rawStep !== undefined) throw scheduleError(`Cron step requires * or range for ${rule.name}`);
|
|
56
|
+
return [integerToken(base, rule, rule.name)];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseCronField(value, rule) {
|
|
60
|
+
if (!value) throw scheduleError(`Cron ${rule.name} field is required`);
|
|
61
|
+
const values = new Set();
|
|
62
|
+
for (const part of value.split(',')) {
|
|
63
|
+
if (!part) throw scheduleError(`Invalid ${rule.name} cron list`);
|
|
64
|
+
for (const number of parseCronPart(part, rule)) values.add(number);
|
|
65
|
+
}
|
|
66
|
+
return values;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseCronExpression(expression) {
|
|
70
|
+
if (typeof expression !== 'string') throw scheduleError('Cron expression must be a string');
|
|
71
|
+
const fields = expression.trim().split(/\s+/);
|
|
72
|
+
if (fields.length !== 5) throw scheduleError('Cron expression must contain exactly 5 fields');
|
|
73
|
+
return Object.freeze(CRON_FIELD_RULES.map((rule, index) => parseCronField(fields[index], rule)));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function cronMatches(parsed, date) {
|
|
77
|
+
if (!(date instanceof Date) || Number.isNaN(date.getTime())) throw new Error('Valid date is required');
|
|
78
|
+
const values = [
|
|
79
|
+
date.getMinutes(),
|
|
80
|
+
date.getHours(),
|
|
81
|
+
date.getDate(),
|
|
82
|
+
date.getMonth() + 1,
|
|
83
|
+
date.getDay(),
|
|
84
|
+
];
|
|
85
|
+
return parsed.every((allowed, index) => allowed.has(values[index]));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeJobOptions(options = {}) {
|
|
89
|
+
const id = String(options.id ?? '').trim();
|
|
90
|
+
if (!JOB_ID_PATTERN.test(id)) {
|
|
91
|
+
throw scheduleError('Scheduled extension job requires a stable id using letters, numbers, dot, underscore, colon or dash');
|
|
92
|
+
}
|
|
93
|
+
const mode = options.mode ?? 'per_process';
|
|
94
|
+
if (!['per_process', 'singleton'].includes(mode)) {
|
|
95
|
+
throw scheduleError('Scheduled extension job mode must be per_process or singleton');
|
|
96
|
+
}
|
|
97
|
+
if ((options.overlap ?? 'skip') !== 'skip') {
|
|
98
|
+
throw scheduleError('Scheduled extension job overlap currently supports skip only');
|
|
99
|
+
}
|
|
100
|
+
if (options.accountability !== 'system') {
|
|
101
|
+
throw scheduleError("Scheduled extension jobs must explicitly set accountability: 'system'");
|
|
102
|
+
}
|
|
103
|
+
return Object.freeze({ id, mode, overlap: 'skip', accountability: 'system' });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function minuteKey(date) {
|
|
107
|
+
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}-${date.getHours()}-${date.getMinutes()}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function scheduleLockName(identity) {
|
|
111
|
+
const digest = createHash('sha256').update(String(identity)).digest('hex').slice(0, 40);
|
|
112
|
+
return `yuncms:schedule:${digest}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class ExtensionScheduler {
|
|
116
|
+
constructor({
|
|
117
|
+
database,
|
|
118
|
+
services,
|
|
119
|
+
schemaCache,
|
|
120
|
+
emitter,
|
|
121
|
+
storage = null,
|
|
122
|
+
logger = console,
|
|
123
|
+
env,
|
|
124
|
+
now = () => new Date(),
|
|
125
|
+
lockRunner = withAdvisoryLock,
|
|
126
|
+
} = {}) {
|
|
127
|
+
if (!database || !services || !schemaCache || !emitter) {
|
|
128
|
+
throw new Error('Extension scheduler requires database, services, schemaCache and emitter');
|
|
129
|
+
}
|
|
130
|
+
this.database = database;
|
|
131
|
+
this.services = services;
|
|
132
|
+
this.schemaCache = schemaCache;
|
|
133
|
+
this.emitter = emitter;
|
|
134
|
+
this.storage = storage;
|
|
135
|
+
this.logger = logger;
|
|
136
|
+
this.env = env;
|
|
137
|
+
this.now = now;
|
|
138
|
+
this.lockRunner = lockRunner;
|
|
139
|
+
this.jobs = new Map();
|
|
140
|
+
this.timer = null;
|
|
141
|
+
this.stopping = false;
|
|
142
|
+
this.runningPromises = new Set();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
register(extensionId, expression, handler, options = {}) {
|
|
146
|
+
if (typeof handler !== 'function') throw scheduleError('Scheduled extension handler must be a function');
|
|
147
|
+
const parsed = parseCronExpression(expression);
|
|
148
|
+
const normalized = normalizeJobOptions(options);
|
|
149
|
+
const identity = `${extensionId}:${normalized.id}`;
|
|
150
|
+
if (this.jobs.has(identity)) throw scheduleError(`Duplicate scheduled extension job: ${identity}`);
|
|
151
|
+
const job = {
|
|
152
|
+
identity,
|
|
153
|
+
extensionId,
|
|
154
|
+
expression,
|
|
155
|
+
parsed,
|
|
156
|
+
handler,
|
|
157
|
+
options: normalized,
|
|
158
|
+
running: false,
|
|
159
|
+
lastMinute: null,
|
|
160
|
+
};
|
|
161
|
+
this.jobs.set(identity, job);
|
|
162
|
+
return () => this.jobs.delete(identity);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async #context(job, date) {
|
|
166
|
+
const accountability = createSystemAccountability();
|
|
167
|
+
const schema = await this.schemaCache.get(this.database);
|
|
168
|
+
return Object.freeze({
|
|
169
|
+
services: this.services,
|
|
170
|
+
database: this.database,
|
|
171
|
+
logger: this.logger,
|
|
172
|
+
env: this.env,
|
|
173
|
+
emitter: this.emitter,
|
|
174
|
+
storage: this.storage,
|
|
175
|
+
accountability,
|
|
176
|
+
requestId: `schedule:${job.identity}:${date.toISOString()}`,
|
|
177
|
+
getSchema: () => this.schemaCache.get(this.database),
|
|
178
|
+
serviceOptions: async () => ({
|
|
179
|
+
accountability,
|
|
180
|
+
database: this.database,
|
|
181
|
+
schema,
|
|
182
|
+
logger: this.logger,
|
|
183
|
+
emitter: this.emitter,
|
|
184
|
+
storage: this.storage,
|
|
185
|
+
permissionCache: null,
|
|
186
|
+
requestId: `schedule:${job.identity}:${date.toISOString()}`,
|
|
187
|
+
}),
|
|
188
|
+
schedule: Object.freeze({
|
|
189
|
+
id: job.options.id,
|
|
190
|
+
extensionId: job.extensionId,
|
|
191
|
+
expression: job.expression,
|
|
192
|
+
mode: job.options.mode,
|
|
193
|
+
scheduledAt: date,
|
|
194
|
+
}),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async #execute(job, date) {
|
|
199
|
+
if (job.running) {
|
|
200
|
+
this.logger?.warn?.('Skipping overlapping YunCMS extension job', { job: job.identity });
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
job.running = true;
|
|
204
|
+
const startedAt = Date.now();
|
|
205
|
+
this.logger?.info?.('Starting YunCMS extension job', { job: job.identity, mode: job.options.mode });
|
|
206
|
+
try {
|
|
207
|
+
const run = async () => job.handler(await this.#context(job, date));
|
|
208
|
+
if (job.options.mode === 'singleton') {
|
|
209
|
+
try {
|
|
210
|
+
await this.lockRunner(
|
|
211
|
+
this.database,
|
|
212
|
+
scheduleLockName(job.identity),
|
|
213
|
+
run,
|
|
214
|
+
{ timeoutSeconds: 0 },
|
|
215
|
+
);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error?.code === 'SCHEMA_LOCK_UNAVAILABLE') {
|
|
218
|
+
this.logger?.info?.('Skipping YunCMS singleton extension job owned by another replica', { job: job.identity });
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
await run();
|
|
225
|
+
}
|
|
226
|
+
this.logger?.info?.('Completed YunCMS extension job', {
|
|
227
|
+
job: job.identity,
|
|
228
|
+
durationMs: Date.now() - startedAt,
|
|
229
|
+
});
|
|
230
|
+
return true;
|
|
231
|
+
} catch (error) {
|
|
232
|
+
this.logger?.error?.('YunCMS extension job failed', {
|
|
233
|
+
job: job.identity,
|
|
234
|
+
durationMs: Date.now() - startedAt,
|
|
235
|
+
code: error?.code ?? null,
|
|
236
|
+
message: error?.message ?? String(error),
|
|
237
|
+
});
|
|
238
|
+
return false;
|
|
239
|
+
} finally {
|
|
240
|
+
job.running = false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async runDue(date = this.now()) {
|
|
245
|
+
if (this.stopping) return [];
|
|
246
|
+
const executions = [];
|
|
247
|
+
const key = minuteKey(date);
|
|
248
|
+
for (const job of this.jobs.values()) {
|
|
249
|
+
if (job.lastMinute === key || !cronMatches(job.parsed, date)) continue;
|
|
250
|
+
job.lastMinute = key;
|
|
251
|
+
const execution = this.#execute(job, date);
|
|
252
|
+
this.runningPromises.add(execution);
|
|
253
|
+
execution.finally(() => this.runningPromises.delete(execution));
|
|
254
|
+
executions.push(execution);
|
|
255
|
+
}
|
|
256
|
+
return Promise.all(executions);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
start() {
|
|
260
|
+
if (this.timer || this.stopping) return;
|
|
261
|
+
const tick = () => this.runDue().catch((error) => {
|
|
262
|
+
this.logger?.error?.('YunCMS extension scheduler tick failed', { code: error?.code ?? null });
|
|
263
|
+
});
|
|
264
|
+
tick();
|
|
265
|
+
this.timer = setInterval(tick, 15_000);
|
|
266
|
+
this.timer.unref?.();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async stop({ timeoutMs = 5_000 } = {}) {
|
|
270
|
+
this.stopping = true;
|
|
271
|
+
if (this.timer) clearInterval(this.timer);
|
|
272
|
+
this.timer = null;
|
|
273
|
+
const running = [...this.runningPromises];
|
|
274
|
+
if (running.length === 0) return true;
|
|
275
|
+
let timeout;
|
|
276
|
+
const completed = await Promise.race([
|
|
277
|
+
Promise.allSettled(running).then(() => true),
|
|
278
|
+
new Promise((resolve) => {
|
|
279
|
+
timeout = setTimeout(() => resolve(false), timeoutMs);
|
|
280
|
+
timeout.unref?.();
|
|
281
|
+
}),
|
|
282
|
+
]);
|
|
283
|
+
if (timeout) clearTimeout(timeout);
|
|
284
|
+
return completed;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export { normalizeJobOptions };
|