@yunsoft/yuncms-api 0.1.3 → 0.1.6
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 +120 -30
- package/src/audit-events.js +18 -0
- package/src/authentication.js +2 -0
- 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 +51 -0
- 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 +81 -52
- package/studio-dist/assets/index-B30LRjIx.js +9 -0
- package/studio-dist/index.html +1 -1
- package/studio-dist/assets/index-DPBu2LEh.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.6",
|
|
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.6",
|
|
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
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
|
|
8
8
|
import { createAuthenticationMiddleware } from './authentication.js';
|
|
9
9
|
import { apiErrorHandler } from './error-response.js';
|
|
10
|
+
import { createPressureLimit } from './pressure-limit.js';
|
|
11
|
+
import { createFixedWindowRateLimit } from './rate-limit.js';
|
|
10
12
|
import { createAuditRouter } from './routes/audit.js';
|
|
11
13
|
import { createAuthRouter } from './routes/auth.js';
|
|
12
14
|
import { createFilesRouter } from './routes/files.js';
|
|
@@ -20,6 +22,12 @@ import { createUsersRouter } from './routes/users.js';
|
|
|
20
22
|
import { createStudioMiddleware } from './studio.js';
|
|
21
23
|
|
|
22
24
|
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
|
|
25
|
+
const CONTENT_SECURITY_POLICY = [
|
|
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'",
|
|
30
|
+
].join('; ');
|
|
23
31
|
|
|
24
32
|
function securityHeaders(req, res, next) {
|
|
25
33
|
res.set('x-content-type-options', 'nosniff');
|
|
@@ -27,6 +35,10 @@ function securityHeaders(req, res, next) {
|
|
|
27
35
|
res.set('referrer-policy', 'no-referrer');
|
|
28
36
|
res.set('permissions-policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
|
|
29
37
|
res.set('cross-origin-resource-policy', 'same-origin');
|
|
38
|
+
res.set('cross-origin-opener-policy', 'same-origin');
|
|
39
|
+
res.set('x-dns-prefetch-control', 'off');
|
|
40
|
+
res.set('content-security-policy', CONTENT_SECURITY_POLICY);
|
|
41
|
+
if (req.secure === true) res.set('strict-transport-security', 'max-age=15552000; includeSubDomains');
|
|
30
42
|
next();
|
|
31
43
|
}
|
|
32
44
|
|
|
@@ -34,14 +46,12 @@ function studioCors(config) {
|
|
|
34
46
|
return (req, res, next) => {
|
|
35
47
|
const origin = req.get('origin');
|
|
36
48
|
const allowedOrigin = config?.server?.studioOrigin;
|
|
37
|
-
|
|
38
49
|
if (origin && allowedOrigin && origin === allowedOrigin) {
|
|
39
50
|
res.set('access-control-allow-origin', origin);
|
|
40
51
|
res.set('vary', 'Origin');
|
|
41
|
-
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');
|
|
42
53
|
res.set('access-control-allow-methods', 'GET,POST,PATCH,DELETE,OPTIONS');
|
|
43
54
|
}
|
|
44
|
-
|
|
45
55
|
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
|
46
56
|
return next();
|
|
47
57
|
};
|
|
@@ -54,16 +64,70 @@ function requestIdentity(req, res, next) {
|
|
|
54
64
|
next();
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
function createApiRateLimit(config, rateLimitStore = null, logger = console) {
|
|
68
|
+
const limits = config?.server?.rateLimit;
|
|
69
|
+
if (!limits?.enabled) return null;
|
|
70
|
+
return createFixedWindowRateLimit({
|
|
71
|
+
windowMs: limits.windowMs,
|
|
72
|
+
max: limits.max,
|
|
73
|
+
maxBuckets: limits.maxBuckets,
|
|
74
|
+
store: limits.store === 'redis' ? rateLimitStore : null,
|
|
75
|
+
scope: 'api',
|
|
76
|
+
failureMode: limits.failureMode ?? 'best-effort',
|
|
77
|
+
logger,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
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
|
+
|
|
57
116
|
export function createApp({
|
|
58
117
|
pool,
|
|
59
118
|
config,
|
|
60
119
|
logger = console,
|
|
61
120
|
serviceRegistry = createCoreServiceRegistry(),
|
|
62
121
|
schemaCache = null,
|
|
122
|
+
permissionCache = null,
|
|
63
123
|
emitter = null,
|
|
64
124
|
storage = null,
|
|
65
125
|
mailer = null,
|
|
126
|
+
rateLimitStore = null,
|
|
127
|
+
redisClient = null,
|
|
128
|
+
externalAuthRegistry = null,
|
|
66
129
|
endpointExtensions = [],
|
|
130
|
+
mcpRouter = null,
|
|
67
131
|
studioRoot = undefined,
|
|
68
132
|
}) {
|
|
69
133
|
if (!pool) throw new Error('Database pool is required');
|
|
@@ -84,33 +148,48 @@ export function createApp({
|
|
|
84
148
|
});
|
|
85
149
|
|
|
86
150
|
app.get('/ready', async (req, res) => {
|
|
151
|
+
const failures = [];
|
|
87
152
|
try {
|
|
88
153
|
const ready = await pingDatabase(pool);
|
|
89
|
-
if (!ready)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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' });
|
|
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
|
+
}
|
|
98
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
|
+
});
|
|
99
178
|
});
|
|
100
179
|
|
|
101
180
|
app.use(createStudioMiddleware({ root: studioRoot }));
|
|
181
|
+
const pressureLimit = createPressureLimit(config.server?.pressure);
|
|
182
|
+
if (pressureLimit) app.use(pressureLimit);
|
|
183
|
+
const apiRateLimit = createApiRateLimit(config, rateLimitStore, logger);
|
|
184
|
+
if (apiRateLimit) app.use(apiRateLimit);
|
|
102
185
|
|
|
103
|
-
app.use(createAuthenticationMiddleware({
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
schemaCache,
|
|
109
|
-
emitter,
|
|
110
|
-
storage,
|
|
111
|
-
}));
|
|
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);
|
|
112
191
|
app.use('/studio-settings', createStudioSettingsRouter());
|
|
113
|
-
app.use('/auth', createAuthRouter({ mailer, config, logger }));
|
|
192
|
+
app.use('/auth', createAuthRouter({ mailer, config, logger, rateLimitStore, externalAuthRegistry }));
|
|
114
193
|
app.use('/items', createItemsRouter());
|
|
115
194
|
app.use('/schema', createSystemSchemaRouter({ schemaCache }));
|
|
116
195
|
app.use('/schema', createSchemaRouter({ schemaCache }));
|
|
@@ -121,20 +200,31 @@ export function createApp({
|
|
|
121
200
|
app.use('/audit', createAuditRouter());
|
|
122
201
|
|
|
123
202
|
for (const extension of endpointExtensions) {
|
|
124
|
-
if (!extension?.id || !extension?.router)
|
|
125
|
-
throw new Error('Invalid endpoint extension runtime entry');
|
|
126
|
-
}
|
|
203
|
+
if (!extension?.id || !extension?.router) throw new Error('Invalid endpoint extension runtime entry');
|
|
127
204
|
app.use(`/extensions/${encodeURIComponent(extension.id)}`, extension.router);
|
|
128
205
|
}
|
|
129
206
|
|
|
130
207
|
app.use((req, res) => {
|
|
131
|
-
res.status(404).json({
|
|
132
|
-
|
|
133
|
-
|
|
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);
|
|
134
218
|
});
|
|
135
|
-
|
|
136
219
|
app.use(apiErrorHandler(logger));
|
|
137
220
|
return app;
|
|
138
221
|
}
|
|
139
222
|
|
|
140
|
-
export {
|
|
223
|
+
export {
|
|
224
|
+
CONTENT_SECURITY_POLICY,
|
|
225
|
+
createApiRateLimit,
|
|
226
|
+
createRequestEvents,
|
|
227
|
+
requestIdentity,
|
|
228
|
+
securityHeaders,
|
|
229
|
+
studioCors,
|
|
230
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const INTERNAL_AUDIT_EVENTS = Object.freeze([
|
|
2
|
+
'items.create',
|
|
3
|
+
'items.update',
|
|
4
|
+
'items.delete',
|
|
5
|
+
'files.create',
|
|
6
|
+
'files.update',
|
|
7
|
+
'files.delete',
|
|
8
|
+
'users.create',
|
|
9
|
+
'users.update',
|
|
10
|
+
'users.delete',
|
|
11
|
+
'users.password.update',
|
|
12
|
+
'roles.create',
|
|
13
|
+
'roles.update',
|
|
14
|
+
'roles.delete',
|
|
15
|
+
'permissions.create',
|
|
16
|
+
'permissions.update',
|
|
17
|
+
'permissions.delete',
|
|
18
|
+
]);
|
package/src/authentication.js
CHANGED
|
@@ -25,6 +25,7 @@ export function createAuthenticationMiddleware({
|
|
|
25
25
|
logger,
|
|
26
26
|
services,
|
|
27
27
|
schemaCache = null,
|
|
28
|
+
permissionCache = null,
|
|
28
29
|
emitter = null,
|
|
29
30
|
storage = null,
|
|
30
31
|
}) {
|
|
@@ -79,6 +80,7 @@ export function createAuthenticationMiddleware({
|
|
|
79
80
|
env: config,
|
|
80
81
|
emitter,
|
|
81
82
|
storage,
|
|
83
|
+
permissionCache,
|
|
82
84
|
requestId: req.id,
|
|
83
85
|
});
|
|
84
86
|
next();
|
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 };
|