@goodandready/dsh-cron 0.2.43 → 0.2.45

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/lib/client.js CHANGED
@@ -1441,14 +1441,23 @@ window.__ModuleLoader__.load({
1441
1441
 
1442
1442
  let timerId = null;
1443
1443
  let isDestroyed = false;
1444
+ let pollController = null;
1445
+ const currentGen = fetchGenRef.current;
1444
1446
 
1445
1447
  const doPoll = () => {
1446
- if (isDestroyed) return;
1448
+ if (isDestroyed || currentGen !== fetchGenRef.current) return;
1447
1449
  const headers = {};
1448
1450
  if (lastEtagRef.current) headers['If-None-Match'] = lastEtagRef.current;
1449
1451
 
1450
- fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query), { headers })
1452
+ if (pollController) pollController.abort();
1453
+ pollController = (typeof AbortController !== 'undefined') ? new AbortController() : null;
1454
+
1455
+ const fetchOpts = { headers };
1456
+ if (pollController) fetchOpts.signal = pollController.signal;
1457
+
1458
+ fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query), fetchOpts)
1451
1459
  .then(res => {
1460
+ if (isDestroyed || currentGen !== fetchGenRef.current) return null;
1452
1461
  if (res.status === 304) {
1453
1462
  // Not modified: skip parsing JSON
1454
1463
  return null;
@@ -1458,15 +1467,18 @@ window.__ModuleLoader__.load({
1458
1467
  return res.json();
1459
1468
  })
1460
1469
  .then(data => {
1470
+ if (isDestroyed || currentGen !== fetchGenRef.current) return;
1461
1471
  if (data && data.ok) {
1462
1472
  setTasks(data.tasks || []);
1463
1473
  setRecs(data.recommendations || []);
1464
1474
  setStats(data.stats || null);
1465
1475
  }
1466
1476
  })
1467
- .catch(() => {})
1477
+ .catch(err => {
1478
+ if (err && err.name === 'AbortError') return;
1479
+ })
1468
1480
  .finally(() => {
1469
- if (isDestroyed) return;
1481
+ if (isDestroyed || currentGen !== fetchGenRef.current) return;
1470
1482
  // Adaptive interval: 30s when tab is hidden, 8s when active (#134)
1471
1483
  const intervalMs = (typeof document !== 'undefined' && document.hidden) ? 30000 : 8000;
1472
1484
  timerId = setTimeout(doPoll, intervalMs);
@@ -1490,6 +1502,7 @@ window.__ModuleLoader__.load({
1490
1502
  return () => {
1491
1503
  isDestroyed = true;
1492
1504
  if (timerId) clearTimeout(timerId);
1505
+ if (pollController) pollController.abort();
1493
1506
  if (typeof document !== 'undefined') {
1494
1507
  document.removeEventListener('visibilitychange', onVisibilityChange);
1495
1508
  }
@@ -1507,10 +1520,18 @@ window.__ModuleLoader__.load({
1507
1520
  ...options,
1508
1521
  signal: controller ? controller.signal : options.signal,
1509
1522
  });
1510
- const data = await res.json().catch(() => ({}));
1523
+ let data = {};
1524
+ try {
1525
+ data = await res.json();
1526
+ } catch (jsonErr) {
1527
+ // Re-throw if body reading aborted/timed out (#242)
1528
+ if (jsonErr && (jsonErr.name === 'AbortError' || controller?.signal.aborted)) {
1529
+ throw jsonErr;
1530
+ }
1531
+ }
1511
1532
  return { res, data };
1512
1533
  } catch (err) {
1513
- if (err && err.name === 'AbortError') {
1534
+ if (err && (err.name === 'AbortError' || controller?.signal.aborted)) {
1514
1535
  const timeoutErr = new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
1515
1536
  timeoutErr.isTimeout = true;
1516
1537
  throw timeoutErr;
package/lib/index.js CHANGED
@@ -80,6 +80,7 @@ import {
80
80
  sanitizeSettingsPayload,
81
81
  unwrapConfig,
82
82
  unwrapConfigValue,
83
+ plainConfig,
83
84
  } from './settings.js';
84
85
  import { registerRoutes } from './routes.js';
85
86
 
@@ -90,6 +91,7 @@ export {
90
91
  sanitizeSettingsPayload,
91
92
  unwrapConfig,
92
93
  unwrapConfigValue,
94
+ plainConfig,
93
95
  };
94
96
 
95
97
  export const name = '@goodandready/dsh-cron';
@@ -115,11 +117,31 @@ export {
115
117
  LEGACY_ACTION_MAP,
116
118
  };
117
119
  const pkgUrl = new URL('../package.json', import.meta.url);
120
+ // Helper to access configuration safely in native Cordis Context without throwing missing-inject error (#235)
121
+ function getContextConfig(ctx, rawConfig) {
122
+ try {
123
+ if (ctx && ctx.fiber && ctx.fiber.config) return ctx.fiber.config;
124
+ if (ctx && ctx.scope && ctx.scope.config) return ctx.scope.config;
125
+ if (ctx && ctx.runtime && ctx.runtime.config) return ctx.runtime.config;
126
+ if (ctx && typeof ctx === 'object' && 'config' in ctx) {
127
+ try {
128
+ const c = ctx.config;
129
+ if (c) return c;
130
+ } catch (err) {
131
+ // Ignore missing inject error on native Cordis Context (#235)
132
+ }
133
+ }
134
+ } catch (err) {
135
+ // Ignore context access error and fall back to rawConfig (#235)
136
+ }
137
+ return rawConfig || {};
138
+ }
139
+
118
140
  const pkgVersion = JSON.parse(fs.readFileSync(pkgUrl, 'utf8')).version || '0.2.29';
119
141
 
120
142
  export function apply(ctx, rawConfig) {
121
143
  if (ctx && ctx.logger) setLogger(ctx.logger);
122
- const config = unwrapConfig(rawConfig);
144
+ const config = plainConfig(rawConfig);
123
145
  const storePath = (config && (config.cronDir || config.storePath)) ? (config.storePath || path.join(config.cronDir, 'tasks.json')) : undefined;
124
146
  const store = new TaskStore(storePath);
125
147
 
@@ -213,7 +235,8 @@ export function apply(ctx, rawConfig) {
213
235
  function syncVolatileConfig(updatedConfig) {
214
236
  try {
215
237
  const isPlainConfigObject = updatedConfig && typeof updatedConfig === 'object' && !Array.isArray(updatedConfig);
216
- const cfg = isPlainConfigObject ? { ...(ctx.config || config || {}), ...updatedConfig } : (ctx.config || config || {});
238
+ const liveConfig = getContextConfig(ctx, rawConfig);
239
+ const cfg = isPlainConfigObject ? { ...liveConfig, ...updatedConfig } : liveConfig;
217
240
  const unwrapped = unwrapConfig(cfg);
218
241
  activeVolatileConfig = { ...activeVolatileConfig, ...unwrapped };
219
242
  if (typeof unwrapped.maxConcurrent === 'number') {
@@ -284,7 +307,7 @@ export function apply(ctx, rawConfig) {
284
307
  try {
285
308
  if (req.method === 'GET') {
286
309
  const unwrapped = {
287
- ...unwrapConfig(ctx.config || config || {}),
310
+ ...unwrapConfig(getContextConfig(ctx, rawConfig)),
288
311
  ...activeVolatileConfig,
289
312
  };
290
313
  const clientSettings = store.getClientSettings();
package/lib/settings.js CHANGED
@@ -53,28 +53,21 @@ export const Config = z.object({
53
53
 
54
54
 
55
55
  /**
56
- * Unwrap a volatile config reference or wrap a config object in a transparent Proxy.
56
+ * Deeply unwrap Cosmokit/Schemastery Volatile boxes into plain primitives/objects/arrays (#282).
57
57
  */
58
- export function unwrapConfigValue(val) {
59
- if (val && typeof val === 'object' && typeof val.get === 'function') {
60
- return val.get();
58
+ export function plainConfig(value) {
59
+ if (value === null || value === undefined) return value;
60
+ if (typeof value === 'function') return plainConfig(value());
61
+ if (typeof value.get === 'function') return plainConfig(value.get());
62
+ if (Array.isArray(value)) return value.map(plainConfig);
63
+ if (typeof value === 'object') {
64
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]));
61
65
  }
62
- return val;
66
+ return value;
63
67
  }
64
68
 
65
- export function unwrapConfig(cfg) {
66
- if (!cfg || typeof cfg !== 'object') return cfg;
67
- if (typeof cfg.get === 'function') return unwrapConfig(cfg.get());
68
- return new Proxy(cfg, {
69
- get(target, prop, receiver) {
70
- const val = Reflect.get(target, prop, receiver);
71
- if (val && typeof val === 'object' && typeof val.get === 'function') {
72
- return val.get();
73
- }
74
- return val;
75
- }
76
- });
77
- }
69
+ export const unwrapConfigValue = plainConfig;
70
+ export const unwrapConfig = plainConfig;
78
71
 
79
72
  export const SETTINGS_SYNC_KEYS = [
80
73
  'botToken', 'botTokenRef', 'telegramWebhookSecret', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
package/lib/store.js CHANGED
@@ -190,8 +190,12 @@ export class TaskStore {
190
190
  const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
191
191
  this._currentAsyncTmp = tmp;
192
192
 
193
- // Use compact serialization without whitespace indentation to eliminate event loop lag on large stores (#244)
193
+ // Yield to the event loop before CPU serialization so pending timers/I/O are serviced (#244)
194
+ await new Promise((resolve) => setImmediate(resolve));
195
+ // Compact serialization reduces payload size; disk write and rename are offloaded asynchronously via fs.promises (#244)
194
196
  const payload = JSON.stringify(data);
197
+ // Yield after serialization before starting disk write I/O
198
+ await new Promise((resolve) => setImmediate(resolve));
195
199
  await fs.promises.writeFile(tmp, payload, { encoding: 'utf-8', mode: 0o600 });
196
200
  bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
197
201
 
@@ -209,8 +213,11 @@ export class TaskStore {
209
213
  this._lastWrittenVersion = version;
210
214
  } catch (err) {
211
215
  this._currentAsyncTmp = null;
212
- // If tmp was unlinked by newer sync save, rename will fail with ENOENT - safely ignore
213
- if (err.code !== 'ENOENT') throw err;
216
+ // If tmp was unlinked by newer sync save, rename will fail with ENOENT - safely ignore only in that case (#244)
217
+ if (err.code === 'ENOENT' && (this._lastSyncVersion || 0) >= version) {
218
+ return;
219
+ }
220
+ throw err;
214
221
  }
215
222
 
216
223
  const now = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.43",
3
+ "version": "0.2.45",
4
4
  "description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",