space-data-module-sdk 0.1.0 → 0.2.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/LICENSE +190 -0
- package/README.md +174 -83
- package/bin/space-data-module.js +24 -0
- package/package.json +8 -3
- package/schemas/ModuleBundle.fbs +108 -0
- package/schemas/PluginManifest.fbs +26 -1
- package/src/bundle/codec.js +244 -0
- package/src/bundle/constants.js +8 -0
- package/src/bundle/index.js +3 -0
- package/src/bundle/wasm.js +447 -0
- package/src/compiler/compileModule.js +189 -41
- package/src/compliance/pluginCompliance.js +334 -0
- package/src/generated/orbpro/manifest/capability-kind.d.ts +27 -2
- package/src/generated/orbpro/manifest/capability-kind.js +26 -1
- package/src/generated/orbpro/manifest/capability-kind.ts +25 -0
- package/src/generated/orbpro/module/canonicalization-rule.d.ts +48 -0
- package/src/generated/orbpro/module/canonicalization-rule.js +95 -0
- package/src/generated/orbpro/module/canonicalization-rule.ts +142 -0
- package/src/generated/orbpro/module/module-bundle-entry-role.d.ts +11 -0
- package/src/generated/orbpro/module/module-bundle-entry-role.js +14 -0
- package/src/generated/orbpro/module/module-bundle-entry-role.ts +15 -0
- package/src/generated/orbpro/module/module-bundle-entry.d.ts +97 -0
- package/src/generated/orbpro/module/module-bundle-entry.js +219 -0
- package/src/generated/orbpro/module/module-bundle-entry.ts +287 -0
- package/src/generated/orbpro/module/module-bundle.d.ts +86 -0
- package/src/generated/orbpro/module/module-bundle.js +213 -0
- package/src/generated/orbpro/module/module-bundle.ts +277 -0
- package/src/generated/orbpro/module/module-payload-encoding.d.ts +9 -0
- package/src/generated/orbpro/module/module-payload-encoding.js +12 -0
- package/src/generated/orbpro/module/module-payload-encoding.ts +13 -0
- package/src/generated/orbpro/module.d.ts +5 -0
- package/src/generated/orbpro/module.js +7 -0
- package/src/generated/orbpro/module.ts +9 -0
- package/src/host/abi.js +282 -0
- package/src/host/cron.js +247 -0
- package/src/host/index.js +3 -0
- package/src/host/nodeHost.js +2165 -0
- package/src/index.d.ts +880 -0
- package/src/index.js +9 -2
- package/src/manifest/normalize.js +32 -1
- package/src/runtime/constants.js +18 -1
- package/src/transport/pki.js +0 -5
- package/src/utils/encoding.js +9 -1
- package/src/utils/wasmCrypto.js +49 -1
package/src/host/abi.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
const textDecoder = new TextDecoder();
|
|
2
|
+
const textEncoder = new TextEncoder();
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_HOSTCALL_IMPORT_MODULE = "sdn_host";
|
|
5
|
+
export const HOSTCALL_STATUS_OK = 0;
|
|
6
|
+
export const HOSTCALL_STATUS_ERROR = 1;
|
|
7
|
+
|
|
8
|
+
export const NodeHostSyncHostcallOperations = Object.freeze([
|
|
9
|
+
"host.runtimeTarget",
|
|
10
|
+
"host.listCapabilities",
|
|
11
|
+
"host.listSupportedCapabilities",
|
|
12
|
+
"host.listOperations",
|
|
13
|
+
"host.hasCapability",
|
|
14
|
+
"clock.now",
|
|
15
|
+
"clock.monotonicNow",
|
|
16
|
+
"clock.nowIso",
|
|
17
|
+
"schedule.parse",
|
|
18
|
+
"schedule.matches",
|
|
19
|
+
"schedule.next",
|
|
20
|
+
"filesystem.resolvePath",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function assertNonEmptyString(value, label) {
|
|
24
|
+
const normalized = String(value ?? "").trim();
|
|
25
|
+
if (!normalized) {
|
|
26
|
+
throw new TypeError(`${label} is required.`);
|
|
27
|
+
}
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getMemoryBuffer(getMemory) {
|
|
32
|
+
if (typeof getMemory !== "function") {
|
|
33
|
+
throw new TypeError("getMemory must be a function returning WebAssembly.Memory.");
|
|
34
|
+
}
|
|
35
|
+
const memory = getMemory();
|
|
36
|
+
if (!memory || typeof memory !== "object" || !("buffer" in memory)) {
|
|
37
|
+
throw new TypeError("getMemory must return a WebAssembly.Memory-like object.");
|
|
38
|
+
}
|
|
39
|
+
const buffer = memory.buffer;
|
|
40
|
+
if (!(buffer instanceof ArrayBuffer || buffer instanceof SharedArrayBuffer)) {
|
|
41
|
+
throw new TypeError("Hostcall memory buffer must be an ArrayBuffer or SharedArrayBuffer.");
|
|
42
|
+
}
|
|
43
|
+
return buffer;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readMemoryBytes(getMemory, ptr, len, label) {
|
|
47
|
+
if (!Number.isInteger(ptr) || ptr < 0) {
|
|
48
|
+
throw new RangeError(`${label} pointer must be a non-negative integer.`);
|
|
49
|
+
}
|
|
50
|
+
if (!Number.isInteger(len) || len < 0) {
|
|
51
|
+
throw new RangeError(`${label} length must be a non-negative integer.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const buffer = getMemoryBuffer(getMemory);
|
|
55
|
+
if (ptr + len > buffer.byteLength) {
|
|
56
|
+
throw new RangeError(`${label} range exceeds guest memory bounds.`);
|
|
57
|
+
}
|
|
58
|
+
return new Uint8Array(buffer, ptr, len);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeMemoryBytes(getMemory, ptr, bytes, maxLen) {
|
|
62
|
+
if (!Number.isInteger(ptr) || ptr < 0) {
|
|
63
|
+
throw new RangeError("Response pointer must be a non-negative integer.");
|
|
64
|
+
}
|
|
65
|
+
if (!Number.isInteger(maxLen) || maxLen < 0) {
|
|
66
|
+
throw new RangeError("Response max length must be a non-negative integer.");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const payload = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
70
|
+
const buffer = getMemoryBuffer(getMemory);
|
|
71
|
+
const bytesToCopy = Math.min(payload.length, maxLen);
|
|
72
|
+
if (ptr + bytesToCopy > buffer.byteLength) {
|
|
73
|
+
throw new RangeError("Response range exceeds guest memory bounds.");
|
|
74
|
+
}
|
|
75
|
+
new Uint8Array(buffer, ptr, bytesToCopy).set(payload.subarray(0, bytesToCopy));
|
|
76
|
+
return bytesToCopy;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseJsonPayload(bytes) {
|
|
80
|
+
if (bytes.length === 0) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const text = textDecoder.decode(bytes);
|
|
84
|
+
if (!text.trim()) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return JSON.parse(text);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function serializeHostcallError(error, operation = null) {
|
|
91
|
+
return {
|
|
92
|
+
name: error?.name ?? "Error",
|
|
93
|
+
message: error?.message ?? String(error),
|
|
94
|
+
code: error?.code ?? null,
|
|
95
|
+
operation: error?.operation ?? operation,
|
|
96
|
+
capability: error?.capability ?? null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isPromiseLike(value) {
|
|
101
|
+
return (
|
|
102
|
+
value !== null &&
|
|
103
|
+
typeof value === "object" &&
|
|
104
|
+
typeof value.then === "function"
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function dispatchNodeHostSyncOperation(host, operation, params = null) {
|
|
109
|
+
const normalized = assertNonEmptyString(operation, "Hostcall operation");
|
|
110
|
+
switch (normalized) {
|
|
111
|
+
case "host.runtimeTarget":
|
|
112
|
+
return host.runtimeTarget;
|
|
113
|
+
case "host.listCapabilities":
|
|
114
|
+
return host.listCapabilities();
|
|
115
|
+
case "host.listSupportedCapabilities":
|
|
116
|
+
return host.listSupportedCapabilities();
|
|
117
|
+
case "host.listOperations":
|
|
118
|
+
return host.listOperations();
|
|
119
|
+
case "host.hasCapability":
|
|
120
|
+
return host.hasCapability(params?.capability);
|
|
121
|
+
case "clock.now":
|
|
122
|
+
return host.clock.now();
|
|
123
|
+
case "clock.monotonicNow":
|
|
124
|
+
return host.clock.monotonicNow();
|
|
125
|
+
case "clock.nowIso":
|
|
126
|
+
return host.clock.nowIso();
|
|
127
|
+
case "schedule.parse":
|
|
128
|
+
return host.schedule.parse(params?.expression);
|
|
129
|
+
case "schedule.matches":
|
|
130
|
+
return host.schedule.matches(params?.expression, params?.date);
|
|
131
|
+
case "schedule.next":
|
|
132
|
+
return host.schedule.next(params?.expression, params?.from);
|
|
133
|
+
case "filesystem.resolvePath":
|
|
134
|
+
return host.filesystem.resolvePath(params?.path);
|
|
135
|
+
default:
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Operation "${normalized}" is not available in the synchronous hostcall ABI.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function createNodeHostSyncDispatcher(host) {
|
|
143
|
+
if (!host || typeof host !== "object") {
|
|
144
|
+
throw new TypeError("createNodeHostSyncDispatcher requires a host object.");
|
|
145
|
+
}
|
|
146
|
+
return (operation, params = null) =>
|
|
147
|
+
dispatchNodeHostSyncOperation(host, operation, params);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createJsonHostcallBridge(options = {}) {
|
|
151
|
+
const dispatch = options.dispatch;
|
|
152
|
+
if (typeof dispatch !== "function") {
|
|
153
|
+
throw new TypeError("createJsonHostcallBridge requires a dispatch function.");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const getMemory = options.getMemory;
|
|
157
|
+
const moduleName = assertNonEmptyString(
|
|
158
|
+
options.moduleName ?? DEFAULT_HOSTCALL_IMPORT_MODULE,
|
|
159
|
+
"Hostcall import module name",
|
|
160
|
+
);
|
|
161
|
+
const maxRequestBytes = Number.isInteger(options.maxRequestBytes)
|
|
162
|
+
? options.maxRequestBytes
|
|
163
|
+
: 64 * 1024;
|
|
164
|
+
const maxResponseBytes = Number.isInteger(options.maxResponseBytes)
|
|
165
|
+
? options.maxResponseBytes
|
|
166
|
+
: 1024 * 1024;
|
|
167
|
+
|
|
168
|
+
let lastStatusCode = HOSTCALL_STATUS_OK;
|
|
169
|
+
let lastEnvelope = { ok: true, result: null };
|
|
170
|
+
let lastResponseBytes = textEncoder.encode(JSON.stringify(lastEnvelope));
|
|
171
|
+
|
|
172
|
+
function setEnvelope(statusCode, envelope) {
|
|
173
|
+
const encoded = textEncoder.encode(JSON.stringify(envelope));
|
|
174
|
+
if (encoded.length > maxResponseBytes) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Hostcall response exceeds ${maxResponseBytes} byte limit.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
lastStatusCode = statusCode;
|
|
180
|
+
lastEnvelope = envelope;
|
|
181
|
+
lastResponseBytes = encoded;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function callJson(operationPtr, operationLen, payloadPtr, payloadLen) {
|
|
185
|
+
try {
|
|
186
|
+
if (payloadLen > maxRequestBytes) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Hostcall request exceeds ${maxRequestBytes} byte limit.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const operation = textDecoder.decode(
|
|
192
|
+
readMemoryBytes(getMemory, operationPtr, operationLen, "Operation"),
|
|
193
|
+
);
|
|
194
|
+
const params = parseJsonPayload(
|
|
195
|
+
readMemoryBytes(getMemory, payloadPtr, payloadLen, "Payload"),
|
|
196
|
+
);
|
|
197
|
+
const result = dispatch(operation, params);
|
|
198
|
+
if (isPromiseLike(result)) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Operation "${operation}" returned a Promise. The synchronous hostcall ABI only supports synchronous operations.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
setEnvelope(HOSTCALL_STATUS_OK, {
|
|
204
|
+
ok: true,
|
|
205
|
+
result,
|
|
206
|
+
});
|
|
207
|
+
return HOSTCALL_STATUS_OK;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
try {
|
|
210
|
+
setEnvelope(HOSTCALL_STATUS_ERROR, {
|
|
211
|
+
ok: false,
|
|
212
|
+
error: serializeHostcallError(error),
|
|
213
|
+
});
|
|
214
|
+
} catch (serializationError) {
|
|
215
|
+
setEnvelope(HOSTCALL_STATUS_ERROR, {
|
|
216
|
+
ok: false,
|
|
217
|
+
error: serializeHostcallError(serializationError),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return HOSTCALL_STATUS_ERROR;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function responseLen() {
|
|
225
|
+
return lastResponseBytes.length;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function readResponse(dstPtr, dstLen) {
|
|
229
|
+
return writeMemoryBytes(getMemory, dstPtr, lastResponseBytes, dstLen);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function clearResponse() {
|
|
233
|
+
setEnvelope(HOSTCALL_STATUS_OK, {
|
|
234
|
+
ok: true,
|
|
235
|
+
result: null,
|
|
236
|
+
});
|
|
237
|
+
return HOSTCALL_STATUS_OK;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function lastStatus() {
|
|
241
|
+
return lastStatusCode;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
moduleName,
|
|
246
|
+
imports: {
|
|
247
|
+
[moduleName]: {
|
|
248
|
+
call_json: callJson,
|
|
249
|
+
response_len: responseLen,
|
|
250
|
+
read_response: readResponse,
|
|
251
|
+
clear_response: clearResponse,
|
|
252
|
+
last_status_code: lastStatus,
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
getLastEnvelope() {
|
|
256
|
+
return structuredClone(lastEnvelope);
|
|
257
|
+
},
|
|
258
|
+
getLastResponseBytes() {
|
|
259
|
+
return new Uint8Array(lastResponseBytes);
|
|
260
|
+
},
|
|
261
|
+
getLastResponseText() {
|
|
262
|
+
return textDecoder.decode(lastResponseBytes);
|
|
263
|
+
},
|
|
264
|
+
getLastResponseJson() {
|
|
265
|
+
return JSON.parse(this.getLastResponseText());
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function createNodeHostSyncHostcallBridge(options = {}) {
|
|
271
|
+
const host = options.host;
|
|
272
|
+
if (!host || typeof host !== "object") {
|
|
273
|
+
throw new TypeError(
|
|
274
|
+
"createNodeHostSyncHostcallBridge requires a host instance.",
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return createJsonHostcallBridge({
|
|
279
|
+
...options,
|
|
280
|
+
dispatch: createNodeHostSyncDispatcher(host),
|
|
281
|
+
});
|
|
282
|
+
}
|
package/src/host/cron.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
const MONTH_ALIASES = Object.freeze({
|
|
2
|
+
JAN: 1,
|
|
3
|
+
FEB: 2,
|
|
4
|
+
MAR: 3,
|
|
5
|
+
APR: 4,
|
|
6
|
+
MAY: 5,
|
|
7
|
+
JUN: 6,
|
|
8
|
+
JUL: 7,
|
|
9
|
+
AUG: 8,
|
|
10
|
+
SEP: 9,
|
|
11
|
+
OCT: 10,
|
|
12
|
+
NOV: 11,
|
|
13
|
+
DEC: 12,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const DAY_OF_WEEK_ALIASES = Object.freeze({
|
|
17
|
+
SUN: 0,
|
|
18
|
+
MON: 1,
|
|
19
|
+
TUE: 2,
|
|
20
|
+
WED: 3,
|
|
21
|
+
THU: 4,
|
|
22
|
+
FRI: 5,
|
|
23
|
+
SAT: 6,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function coerceDate(value, label) {
|
|
27
|
+
if (value instanceof Date) {
|
|
28
|
+
if (Number.isNaN(value.getTime())) {
|
|
29
|
+
throw new TypeError(`${label} must be a valid date.`);
|
|
30
|
+
}
|
|
31
|
+
return new Date(value.getTime());
|
|
32
|
+
}
|
|
33
|
+
const date = new Date(value ?? Date.now());
|
|
34
|
+
if (Number.isNaN(date.getTime())) {
|
|
35
|
+
throw new TypeError(`${label} must be a valid date.`);
|
|
36
|
+
}
|
|
37
|
+
return date;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parsePositiveInteger(value, label) {
|
|
41
|
+
const parsed = Number(value);
|
|
42
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
43
|
+
throw new Error(`${label} must be a positive integer.`);
|
|
44
|
+
}
|
|
45
|
+
return parsed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseFieldValue(value, label, min, max, aliases = null) {
|
|
49
|
+
const normalized = String(value ?? "").trim().toUpperCase();
|
|
50
|
+
if (!normalized) {
|
|
51
|
+
throw new Error(`${label} contains an empty value.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let parsedValue = null;
|
|
55
|
+
if (/^\d+$/.test(normalized)) {
|
|
56
|
+
parsedValue = Number(normalized);
|
|
57
|
+
} else if (aliases && Object.hasOwn(aliases, normalized)) {
|
|
58
|
+
parsedValue = aliases[normalized];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!Number.isInteger(parsedValue) || parsedValue < min || parsedValue > max) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`${label} value "${value}" must be between ${min} and ${max}.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return parsedValue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function expandFieldPart(part, label, min, max, aliases = null) {
|
|
71
|
+
const trimmed = String(part ?? "").trim();
|
|
72
|
+
if (!trimmed) {
|
|
73
|
+
throw new Error(`${label} contains an empty list entry.`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const [rangeSpec, stepSpec, extraStep] = trimmed.split("/");
|
|
77
|
+
if (extraStep !== undefined) {
|
|
78
|
+
throw new Error(`${label} contains too many "/" step delimiters.`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const step = stepSpec === undefined ? 1 : parsePositiveInteger(stepSpec, label);
|
|
82
|
+
let start = min;
|
|
83
|
+
let end = max;
|
|
84
|
+
|
|
85
|
+
if (rangeSpec !== "*") {
|
|
86
|
+
const [startSpec, endSpec, extraRange] = rangeSpec.split("-");
|
|
87
|
+
if (extraRange !== undefined) {
|
|
88
|
+
throw new Error(`${label} contains too many "-" range delimiters.`);
|
|
89
|
+
}
|
|
90
|
+
start = parseFieldValue(startSpec, label, min, max, aliases);
|
|
91
|
+
end =
|
|
92
|
+
endSpec === undefined
|
|
93
|
+
? start
|
|
94
|
+
: parseFieldValue(endSpec, label, min, max, aliases);
|
|
95
|
+
if (end < start) {
|
|
96
|
+
throw new Error(`${label} range "${rangeSpec}" is reversed.`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const values = [];
|
|
101
|
+
for (let value = start; value <= end; value += step) {
|
|
102
|
+
values.push(value);
|
|
103
|
+
}
|
|
104
|
+
return values;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildValueSet(field, label, min, max, aliases = null) {
|
|
108
|
+
const values = new Set();
|
|
109
|
+
for (const part of String(field ?? "").split(",")) {
|
|
110
|
+
for (const value of expandFieldPart(part, label, min, max, aliases)) {
|
|
111
|
+
values.add(value);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return values;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sortNumeric(values) {
|
|
118
|
+
return Array.from(values).sort((left, right) => left - right);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeDayOfWeekValues(values) {
|
|
122
|
+
const normalized = new Set();
|
|
123
|
+
for (const value of values) {
|
|
124
|
+
normalized.add(value === 7 ? 0 : value);
|
|
125
|
+
}
|
|
126
|
+
return normalized;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildFullValueSet(min, max, normalize = (values) => values) {
|
|
130
|
+
const values = new Set();
|
|
131
|
+
for (let value = min; value <= max; value += 1) {
|
|
132
|
+
values.add(value);
|
|
133
|
+
}
|
|
134
|
+
return normalize(values);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildCronField(name, source, min, max, aliases = null, normalize = null) {
|
|
138
|
+
const rawValues = buildValueSet(source, name, min, max, aliases);
|
|
139
|
+
const normalizedValues = normalize ? normalize(rawValues) : rawValues;
|
|
140
|
+
const fullDomain = buildFullValueSet(min, max, normalize ?? ((values) => values));
|
|
141
|
+
return {
|
|
142
|
+
source: String(source ?? "").trim(),
|
|
143
|
+
values: sortNumeric(normalizedValues),
|
|
144
|
+
hasWildcard: normalizedValues.size === fullDomain.size,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function normalizeCronInput(expressionOrSchedule) {
|
|
149
|
+
if (
|
|
150
|
+
expressionOrSchedule &&
|
|
151
|
+
typeof expressionOrSchedule === "object" &&
|
|
152
|
+
!Array.isArray(expressionOrSchedule) &&
|
|
153
|
+
typeof expressionOrSchedule.expression === "string" &&
|
|
154
|
+
expressionOrSchedule.minute &&
|
|
155
|
+
expressionOrSchedule.hour &&
|
|
156
|
+
expressionOrSchedule.dayOfMonth &&
|
|
157
|
+
expressionOrSchedule.month &&
|
|
158
|
+
expressionOrSchedule.dayOfWeek
|
|
159
|
+
) {
|
|
160
|
+
return expressionOrSchedule;
|
|
161
|
+
}
|
|
162
|
+
return parseCronExpression(expressionOrSchedule);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function parseCronExpression(expression) {
|
|
166
|
+
const normalized = String(expression ?? "").trim();
|
|
167
|
+
if (!normalized) {
|
|
168
|
+
throw new Error("Cron expression is required.");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const fields = normalized.split(/\s+/);
|
|
172
|
+
if (fields.length !== 5) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
"Cron expression must contain exactly 5 fields: minute hour day-of-month month day-of-week.",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = fields;
|
|
179
|
+
return {
|
|
180
|
+
expression: normalized,
|
|
181
|
+
minute: buildCronField("minute", minute, 0, 59),
|
|
182
|
+
hour: buildCronField("hour", hour, 0, 23),
|
|
183
|
+
dayOfMonth: buildCronField("day-of-month", dayOfMonth, 1, 31),
|
|
184
|
+
month: buildCronField("month", month, 1, 12, MONTH_ALIASES),
|
|
185
|
+
dayOfWeek: buildCronField(
|
|
186
|
+
"day-of-week",
|
|
187
|
+
dayOfWeek,
|
|
188
|
+
0,
|
|
189
|
+
7,
|
|
190
|
+
DAY_OF_WEEK_ALIASES,
|
|
191
|
+
normalizeDayOfWeekValues,
|
|
192
|
+
),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function matchesCronExpression(expressionOrSchedule, date = Date.now()) {
|
|
197
|
+
const schedule = normalizeCronInput(expressionOrSchedule);
|
|
198
|
+
const candidate = coerceDate(date, "Cron candidate date");
|
|
199
|
+
const minute = candidate.getMinutes();
|
|
200
|
+
const hour = candidate.getHours();
|
|
201
|
+
const dayOfMonth = candidate.getDate();
|
|
202
|
+
const month = candidate.getMonth() + 1;
|
|
203
|
+
const dayOfWeek = candidate.getDay();
|
|
204
|
+
|
|
205
|
+
if (!schedule.minute.values.includes(minute)) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
if (!schedule.hour.values.includes(hour)) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
if (!schedule.month.values.includes(month)) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const dayOfMonthMatches = schedule.dayOfMonth.values.includes(dayOfMonth);
|
|
216
|
+
const dayOfWeekMatches = schedule.dayOfWeek.values.includes(dayOfWeek);
|
|
217
|
+
|
|
218
|
+
if (!schedule.dayOfMonth.hasWildcard && !schedule.dayOfWeek.hasWildcard) {
|
|
219
|
+
return dayOfMonthMatches || dayOfWeekMatches;
|
|
220
|
+
}
|
|
221
|
+
if (!schedule.dayOfMonth.hasWildcard) {
|
|
222
|
+
return dayOfMonthMatches;
|
|
223
|
+
}
|
|
224
|
+
if (!schedule.dayOfWeek.hasWildcard) {
|
|
225
|
+
return dayOfWeekMatches;
|
|
226
|
+
}
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function nextCronOccurrence(expressionOrSchedule, from = Date.now()) {
|
|
231
|
+
const schedule = normalizeCronInput(expressionOrSchedule);
|
|
232
|
+
const cursor = coerceDate(from, "Cron start date");
|
|
233
|
+
cursor.setSeconds(0, 0);
|
|
234
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
235
|
+
|
|
236
|
+
const maxIterations = 60 * 24 * 366 * 5;
|
|
237
|
+
for (let index = 0; index < maxIterations; index += 1) {
|
|
238
|
+
if (matchesCronExpression(schedule, cursor)) {
|
|
239
|
+
return new Date(cursor.getTime());
|
|
240
|
+
}
|
|
241
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
throw new Error(
|
|
245
|
+
`No cron occurrence found for "${schedule.expression}" within the next 5 years.`,
|
|
246
|
+
);
|
|
247
|
+
}
|