@azlib/scheduler 1.1.0 → 1.2.1

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/README.md CHANGED
@@ -26,6 +26,7 @@ Cron-style job scheduler for Node.js, supporting timezone normalization, overlap
26
26
  | `createSchedulerDashboardService(scheduler, options?)` | Function | Operator API: health, list, pause/resume, run-now, retry, CRUD. |
27
27
  | `createSchedulerDashboardFromPersistence(persistence)` | Function | Sidecar dashboard against the same SQL tables (does not start the engine). |
28
28
  | `createSchedulerDashboardServer(options)` | Function | Serves the React UI and JSON API (`@azlib/scheduler/dashboard`). |
29
+ | `createSchedulerDashboardNodeHandler(options)` | Function | Same monitor as the standalone server, for Express/`http` hosts. |
29
30
 
30
31
  ### Dashboard
31
32
 
@@ -35,6 +36,7 @@ The dashboard can run **in-process** next to a live `SchedulerService`, or as a
35
36
  import {
36
37
  createSchedulerDashboardServer,
37
38
  createSchedulerDashboardService,
39
+ createSchedulerDashboardNodeHandler,
38
40
  } from "@azlib/scheduler/dashboard";
39
41
 
40
42
  const dashboard = createSchedulerDashboardService(service, { handlers });
@@ -44,6 +46,17 @@ const server = createSchedulerDashboardServer({
44
46
  host: "127.0.0.1",
45
47
  });
46
48
  await server.listen();
49
+
50
+ // Or mount the same monitor on an existing Node/Express server:
51
+ app.use(
52
+ createSchedulerDashboardNodeHandler({
53
+ dashboard,
54
+ basePath: "/scheduler",
55
+ authorize: async ({ authorization }) => {
56
+ // throw DashboardHttpError(401 | 403, message) to reject
57
+ },
58
+ }),
59
+ );
47
60
  ```
48
61
 
49
62
  Sidecar CLI (`azlib-scheduler-dashboard`) and Docker (`packages/scheduler/Dockerfile`, `compose.yaml`):
@@ -22,9 +22,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
22
22
  //#endregion
23
23
  const require_store_dashboard = require("./store-dashboard-Sg4BBWiJ.cjs");
24
24
  let _azlib_std = require("@azlib/std");
25
- let node_fs = require("node:fs");
26
25
  let node_http = require("node:http");
27
26
  node_http = __toESM(node_http, 1);
27
+ let node_fs = require("node:fs");
28
28
  let node_path = require("node:path");
29
29
  let node_url = require("node:url");
30
30
  let node_async_hooks = require("node:async_hooks");
@@ -72,7 +72,11 @@ function parseScheduleFromRecord(record) {
72
72
  if (scheduleType !== "cron" && scheduleType !== "once") throw new DashboardHttpError(400, "scheduleType must be cron or once.");
73
73
  const expression = requiredText(record.expression ?? nested?.expression, "expression");
74
74
  const timezone = (0, _azlib_std.asString)(record.timezone)?.trim() || (0, _azlib_std.asString)(nested?.timezone)?.trim() || DEFAULT_TIMEZONE;
75
- require_store_dashboard.parseSchedule(scheduleType, expression, timezone);
75
+ try {
76
+ require_store_dashboard.parseSchedule(scheduleType, expression, timezone);
77
+ } catch (error) {
78
+ throw new DashboardHttpError(400, error instanceof Error ? error.message : "Invalid schedule.");
79
+ }
76
80
  return {
77
81
  scheduleType,
78
82
  expression,
@@ -102,6 +106,36 @@ function parseUpdateJobBody(body) {
102
106
  return patch;
103
107
  }
104
108
  //#endregion
109
+ //#region src/dashboard/http-path.ts
110
+ function normalizeDashboardBasePath(basePath) {
111
+ const trimmed = basePath?.trim();
112
+ if (!trimmed || trimmed === "/") return "";
113
+ return (trimmed.startsWith("/") ? trimmed : `/${trimmed}`).replace(/\/+$/, "");
114
+ }
115
+ function stripDashboardBasePath(pathname, basePath) {
116
+ const base = normalizeDashboardBasePath(basePath);
117
+ const path = pathname || "/";
118
+ if (!base) return path.startsWith("/") ? path : `/${path}`;
119
+ if (path === base || path === `${base}/`) return "/";
120
+ if (path.startsWith(`${base}/`)) {
121
+ const rest = path.slice(base.length);
122
+ return rest.startsWith("/") ? rest : `/${rest}`;
123
+ }
124
+ return null;
125
+ }
126
+ function resolveDashboardApiBase(pathname) {
127
+ const trimmed = (pathname.split("?")[0] || "/").replace(/\/index\.html$/i, "/").replace(/\/[^/]+\.[a-z0-9]+$/i, "/").replace(/\/+$/, "");
128
+ return trimmed === "" ? "/api" : `${trimmed}/api`;
129
+ }
130
+ function requestPathname(url, originalUrl) {
131
+ const raw = originalUrl || url || "/";
132
+ try {
133
+ return new URL(raw, "http://127.0.0.1").pathname;
134
+ } catch {
135
+ return raw.split("?")[0] || "/";
136
+ }
137
+ }
138
+ //#endregion
105
139
  //#region src/dashboard/public-dir.ts
106
140
  function resolveDashboardPublicDir(fromFileUrl) {
107
141
  const dir = (0, node_path.dirname)((0, node_url.fileURLToPath)(fromFileUrl));
@@ -112,7 +146,7 @@ function resolveDashboardPublicDir(fromFileUrl) {
112
146
  return nested;
113
147
  }
114
148
  //#endregion
115
- //#region src/dashboard/http-server.ts
149
+ //#region src/dashboard/http-handler.ts
116
150
  const MIME_TYPES = {
117
151
  ".css": "text/css; charset=utf-8",
118
152
  ".html": "text/html; charset=utf-8",
@@ -126,7 +160,7 @@ function sendJson(res, status, body) {
126
160
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
127
161
  res.end(JSON.stringify(body));
128
162
  }
129
- function sendError(res, error) {
163
+ function sendDashboardError(res, error) {
130
164
  if (error instanceof DashboardHttpError) {
131
165
  sendJson(res, error.status, { error: error.message });
132
166
  return;
@@ -134,10 +168,17 @@ function sendError(res, error) {
134
168
  const message = error instanceof Error ? error.message : "Unknown error";
135
169
  sendJson(res, message.includes("not found") ? 404 : message.includes("Only failed") ? 400 : message.includes("Unknown handlerKey") ? 400 : 500, { error: message });
136
170
  }
171
+ function hasParsedBody(req) {
172
+ return Object.prototype.hasOwnProperty.call(req, "body");
173
+ }
137
174
  async function readJsonBody(req) {
175
+ if (req.readableEnded && hasParsedBody(req)) return req.body ?? {};
138
176
  const chunks = [];
139
177
  for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
140
- if (chunks.length === 0) return {};
178
+ if (chunks.length === 0) {
179
+ if (hasParsedBody(req)) return req.body ?? {};
180
+ return {};
181
+ }
141
182
  const raw = Buffer.concat(chunks).toString("utf8");
142
183
  if (!raw.trim()) return {};
143
184
  try {
@@ -175,115 +216,158 @@ function servePublicFile(publicDir, urlPath, res) {
175
216
  (0, node_fs.createReadStream)(filePath).pipe(res);
176
217
  return true;
177
218
  }
178
- function createSchedulerDashboardServer(options) {
179
- const port = options.port ?? 9100;
180
- const host = options.host ?? "127.0.0.1";
181
- const publicDir = options.publicDir ?? resolveDashboardPublicDir(require("url").pathToFileURL(__filename).href);
182
- const dashboard = options.dashboard;
183
- const server = node_http.createServer((req, res) => {
184
- handleRequest(req, res).catch((error) => {
185
- if (!res.headersSent) sendError(res, error);
219
+ async function authorizeApi(options, method, pathname, req) {
220
+ if (options.authorize) {
221
+ await options.authorize({
222
+ method,
223
+ pathname,
224
+ authorization: req.headers.authorization
186
225
  });
226
+ return;
227
+ }
228
+ authorizeDashboardRequest({
229
+ token: options.token,
230
+ authorization: req.headers.authorization
187
231
  });
188
- async function handleRequest(req, res) {
189
- const method = req.method ?? "GET";
190
- const pathname = new URL(req.url ?? "/", `http://${host}:${port}`).pathname;
191
- if (method === "GET" && pathname === "/health") {
192
- res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
193
- res.end("ok");
232
+ }
233
+ async function handleApi(dashboard, method, pathname, req, res) {
234
+ if (method === "GET" && pathname === "/api/health") {
235
+ sendJson(res, 200, await dashboard.getHealth());
236
+ return;
237
+ }
238
+ if (method === "GET" && pathname === "/api/handlers") {
239
+ sendJson(res, 200, { handlers: dashboard.listHandlerKeys() });
240
+ return;
241
+ }
242
+ if (method === "GET" && pathname === "/api/jobs") {
243
+ sendJson(res, 200, { items: await dashboard.listItems() });
244
+ return;
245
+ }
246
+ if (method === "POST" && pathname === "/api/jobs") {
247
+ sendJson(res, 201, await dashboard.createJob(parseCreateJobBody(await readJsonBody(req))));
248
+ return;
249
+ }
250
+ const jobExec = matchPath(pathname, "/api/jobs/:jobId/executions");
251
+ if (jobExec && method === "GET") {
252
+ sendJson(res, 200, { executions: await dashboard.listExecutions(jobExec.jobId ?? "") });
253
+ return;
254
+ }
255
+ const jobAction = matchPath(pathname, "/api/jobs/:jobId/:action");
256
+ if (jobAction && method === "POST") {
257
+ const jobId = jobAction.jobId ?? "";
258
+ if (jobAction.action === "pause") {
259
+ await dashboard.pauseJob(jobId);
260
+ res.writeHead(204);
261
+ res.end();
194
262
  return;
195
263
  }
196
- if (pathname === "/api" || pathname.startsWith("/api/")) {
197
- authorizeDashboardRequest({
198
- token: options.token,
199
- authorization: req.headers.authorization
200
- });
201
- await handleApi(method, pathname, req, res);
264
+ if (jobAction.action === "resume") {
265
+ await dashboard.resumeJob(jobId);
266
+ res.writeHead(204);
267
+ res.end();
202
268
  return;
203
269
  }
204
- if (method === "GET" && servePublicFile(publicDir, pathname, res)) return;
270
+ if (jobAction.action === "run") {
271
+ await dashboard.runJob(jobId);
272
+ res.writeHead(204);
273
+ res.end();
274
+ return;
275
+ }
276
+ }
277
+ const retry = matchPath(pathname, "/api/executions/:executionId/retry");
278
+ if (retry && method === "POST") {
279
+ await dashboard.retryExecution(retry.executionId ?? "");
280
+ res.writeHead(204);
281
+ res.end();
282
+ return;
283
+ }
284
+ const job = matchPath(pathname, "/api/jobs/:jobId");
285
+ if (job) {
286
+ const jobId = job.jobId ?? "";
287
+ if (method === "GET") {
288
+ const item = await dashboard.getJob(jobId);
289
+ if (!item) throw new DashboardHttpError(404, `Job not found: ${jobId}`);
290
+ sendJson(res, 200, item);
291
+ return;
292
+ }
293
+ if (method === "PATCH") {
294
+ await dashboard.updateJob(jobId, parseUpdateJobBody(await readJsonBody(req)));
295
+ res.writeHead(204);
296
+ res.end();
297
+ return;
298
+ }
299
+ if (method === "DELETE") {
300
+ await dashboard.deleteJob(jobId);
301
+ res.writeHead(204);
302
+ res.end();
303
+ return;
304
+ }
305
+ }
306
+ sendJson(res, 404, { error: "Not Found" });
307
+ }
308
+ async function handleSchedulerDashboardRequest(req, res, options) {
309
+ const method = req.method ?? "GET";
310
+ const originalUrl = "originalUrl" in req && typeof req.originalUrl === "string" ? req.originalUrl : void 0;
311
+ const pathname = requestPathname(req.url, originalUrl);
312
+ const basePath = normalizeDashboardBasePath(options.basePath);
313
+ const relative = stripDashboardBasePath(pathname, basePath);
314
+ if (relative === null) return false;
315
+ if (method === "GET" && basePath && pathname === basePath) {
316
+ res.writeHead(302, { Location: `${basePath}/` });
317
+ res.end();
318
+ return true;
319
+ }
320
+ const publicDir = options.publicDir ?? resolveDashboardPublicDir(require("url").pathToFileURL(__filename).href);
321
+ try {
322
+ if (method === "GET" && relative === "/health") {
323
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
324
+ res.end("ok");
325
+ return true;
326
+ }
327
+ if (relative === "/api" || relative.startsWith("/api/")) {
328
+ await authorizeApi(options, method, relative, req);
329
+ await handleApi(options.dashboard, method, relative, req, res);
330
+ return true;
331
+ }
332
+ if (method === "GET" && servePublicFile(publicDir, relative, res)) return true;
205
333
  res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
206
334
  res.end("Not Found");
335
+ return true;
336
+ } catch (error) {
337
+ sendDashboardError(res, error);
338
+ return true;
207
339
  }
208
- async function handleApi(method, pathname, req, res) {
209
- try {
210
- if (method === "GET" && pathname === "/api/health") {
211
- sendJson(res, 200, await dashboard.getHealth());
212
- return;
213
- }
214
- if (method === "GET" && pathname === "/api/handlers") {
215
- sendJson(res, 200, { handlers: dashboard.listHandlerKeys() });
216
- return;
217
- }
218
- if (method === "GET" && pathname === "/api/jobs") {
219
- sendJson(res, 200, { items: await dashboard.listItems() });
220
- return;
221
- }
222
- if (method === "POST" && pathname === "/api/jobs") {
223
- sendJson(res, 201, await dashboard.createJob(parseCreateJobBody(await readJsonBody(req))));
224
- return;
225
- }
226
- const jobExec = matchPath(pathname, "/api/jobs/:jobId/executions");
227
- if (jobExec && method === "GET") {
228
- sendJson(res, 200, { executions: await dashboard.listExecutions(jobExec.jobId ?? "") });
229
- return;
230
- }
231
- const jobAction = matchPath(pathname, "/api/jobs/:jobId/:action");
232
- if (jobAction && method === "POST") {
233
- const jobId = jobAction.jobId ?? "";
234
- if (jobAction.action === "pause") {
235
- await dashboard.pauseJob(jobId);
236
- res.writeHead(204);
237
- res.end();
238
- return;
239
- }
240
- if (jobAction.action === "resume") {
241
- await dashboard.resumeJob(jobId);
242
- res.writeHead(204);
243
- res.end();
244
- return;
245
- }
246
- if (jobAction.action === "run") {
247
- await dashboard.runJob(jobId);
248
- res.writeHead(204);
249
- res.end();
250
- return;
251
- }
252
- }
253
- const retry = matchPath(pathname, "/api/executions/:executionId/retry");
254
- if (retry && method === "POST") {
255
- await dashboard.retryExecution(retry.executionId ?? "");
256
- res.writeHead(204);
257
- res.end();
340
+ }
341
+ function createSchedulerDashboardNodeHandler(options) {
342
+ return (req, res, next) => {
343
+ handleSchedulerDashboardRequest(req, res, options).then((handled) => {
344
+ if (!handled) next?.();
345
+ }).catch((error) => {
346
+ if (next) {
347
+ next(error);
258
348
  return;
259
349
  }
260
- const job = matchPath(pathname, "/api/jobs/:jobId");
261
- if (job) {
262
- const jobId = job.jobId ?? "";
263
- if (method === "GET") {
264
- const item = await dashboard.getJob(jobId);
265
- if (!item) throw new DashboardHttpError(404, `Job not found: ${jobId}`);
266
- sendJson(res, 200, item);
267
- return;
268
- }
269
- if (method === "PATCH") {
270
- await dashboard.updateJob(jobId, parseUpdateJobBody(await readJsonBody(req)));
271
- res.writeHead(204);
272
- res.end();
273
- return;
274
- }
275
- if (method === "DELETE") {
276
- await dashboard.deleteJob(jobId);
277
- res.writeHead(204);
278
- res.end();
279
- return;
280
- }
350
+ if (!res.headersSent) sendDashboardError(res, error);
351
+ });
352
+ };
353
+ }
354
+ //#endregion
355
+ //#region src/dashboard/http-server.ts
356
+ function createSchedulerDashboardServer(options) {
357
+ const port = options.port ?? 9100;
358
+ const host = options.host ?? "127.0.0.1";
359
+ const handler = createSchedulerDashboardNodeHandler({
360
+ ...options,
361
+ publicDir: options.publicDir ?? resolveDashboardPublicDir(require("url").pathToFileURL(__filename).href)
362
+ });
363
+ const server = node_http.createServer((req, res) => {
364
+ handler(req, res, () => {
365
+ if (!res.headersSent) {
366
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
367
+ res.end("Not Found");
281
368
  }
282
- sendJson(res, 404, { error: "Not Found" });
283
- } catch (error) {
284
- sendError(res, error);
285
- }
286
- }
369
+ });
370
+ });
287
371
  return {
288
372
  get port() {
289
373
  return port;
@@ -480,6 +564,12 @@ function parseDashboardCliEnv(env) {
480
564
  };
481
565
  }
482
566
  //#endregion
567
+ Object.defineProperty(exports, "DashboardHttpError", {
568
+ enumerable: true,
569
+ get: function() {
570
+ return DashboardHttpError;
571
+ }
572
+ });
483
573
  Object.defineProperty(exports, "createDashboardPersistenceConfig", {
484
574
  enumerable: true,
485
575
  get: function() {
@@ -492,21 +582,51 @@ Object.defineProperty(exports, "createDashboardSqlDriver", {
492
582
  return createDashboardSqlDriver;
493
583
  }
494
584
  });
585
+ Object.defineProperty(exports, "createSchedulerDashboardNodeHandler", {
586
+ enumerable: true,
587
+ get: function() {
588
+ return createSchedulerDashboardNodeHandler;
589
+ }
590
+ });
495
591
  Object.defineProperty(exports, "createSchedulerDashboardServer", {
496
592
  enumerable: true,
497
593
  get: function() {
498
594
  return createSchedulerDashboardServer;
499
595
  }
500
596
  });
597
+ Object.defineProperty(exports, "handleSchedulerDashboardRequest", {
598
+ enumerable: true,
599
+ get: function() {
600
+ return handleSchedulerDashboardRequest;
601
+ }
602
+ });
603
+ Object.defineProperty(exports, "normalizeDashboardBasePath", {
604
+ enumerable: true,
605
+ get: function() {
606
+ return normalizeDashboardBasePath;
607
+ }
608
+ });
501
609
  Object.defineProperty(exports, "parseDashboardCliEnv", {
502
610
  enumerable: true,
503
611
  get: function() {
504
612
  return parseDashboardCliEnv;
505
613
  }
506
614
  });
615
+ Object.defineProperty(exports, "resolveDashboardApiBase", {
616
+ enumerable: true,
617
+ get: function() {
618
+ return resolveDashboardApiBase;
619
+ }
620
+ });
507
621
  Object.defineProperty(exports, "resolveDashboardPublicDir", {
508
622
  enumerable: true,
509
623
  get: function() {
510
624
  return resolveDashboardPublicDir;
511
625
  }
512
626
  });
627
+ Object.defineProperty(exports, "stripDashboardBasePath", {
628
+ enumerable: true,
629
+ get: function() {
630
+ return stripDashboardBasePath;
631
+ }
632
+ });