arcway 0.1.26 → 0.1.27

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.
Files changed (39) hide show
  1. package/client/fetcher.js +4 -1
  2. package/client/hooks/swr-compat.js +4 -2
  3. package/client/hooks/use-api-paginated.js +74 -0
  4. package/client/index.js +1 -0
  5. package/package.json +4 -3
  6. package/server/boot/index.js +12 -6
  7. package/server/build.js +62 -4
  8. package/server/cache/index.js +12 -2
  9. package/server/cache/ttl.js +24 -0
  10. package/server/config/duration.js +49 -0
  11. package/server/config/modules/cache.js +7 -1
  12. package/server/config/modules/jobs.js +14 -1
  13. package/server/config/modules/mail.js +16 -1
  14. package/server/config/modules/pages.js +1 -0
  15. package/server/config/modules/queue.js +7 -1
  16. package/server/config/modules/server.js +13 -1
  17. package/server/config/modules/websocket.js +8 -0
  18. package/server/context.js +61 -2
  19. package/server/db/index.js +6 -1
  20. package/server/events/drivers/memory.js +22 -4
  21. package/server/events/drivers/redis.js +20 -5
  22. package/server/events/handler.js +21 -7
  23. package/server/events/index.js +2 -2
  24. package/server/index.js +0 -1
  25. package/server/jobs/runner.js +3 -2
  26. package/server/jobs/worker-config.js +58 -0
  27. package/server/jobs/worker-entry.js +7 -12
  28. package/server/meta.js +106 -0
  29. package/server/pages/build-plugins.js +2 -1
  30. package/server/pages/handler.js +23 -5
  31. package/server/pages/out-dir.js +103 -2
  32. package/server/pages/pages-router.js +3 -2
  33. package/server/pages/ssr.js +55 -18
  34. package/server/server.js +13 -1
  35. package/server/session/index.js +5 -1
  36. package/server/static/index.js +5 -2
  37. package/server/web-server.js +3 -3
  38. package/server/ws/realtime.js +24 -8
  39. package/server/ws/ws-router.js +24 -8
package/client/fetcher.js CHANGED
@@ -2,13 +2,15 @@ class ApiError extends Error {
2
2
  status;
3
3
  code;
4
4
  details;
5
+ data;
5
6
 
6
- constructor(status, code, message, details) {
7
+ constructor(status, code, message, details, data) {
7
8
  super(message);
8
9
  this.name = 'ApiError';
9
10
  this.status = status;
10
11
  this.code = code;
11
12
  this.details = details;
13
+ this.data = data;
12
14
  }
13
15
  }
14
16
 
@@ -39,6 +41,7 @@ async function soloFetch(url, init) {
39
41
  json.error.code || 'UNKNOWN',
40
42
  json.error.message || res.statusText,
41
43
  json.error.details,
44
+ json.data,
42
45
  );
43
46
  }
44
47
  throw new ApiError(res.status, 'UNKNOWN', json?.message || res.statusText);
@@ -1,4 +1,5 @@
1
1
  import * as SWR from 'swr';
2
+ import * as SWRInfinite from 'swr/infinite';
2
3
  import * as SWRMutation from 'swr/mutation';
3
4
 
4
5
  function resolveHook(mod) {
@@ -15,11 +16,12 @@ function resolveNamed(mod, key) {
15
16
  }
16
17
 
17
18
  const useSWR = resolveHook(SWR);
19
+ const useSWRInfinite = resolveHook(SWRInfinite);
18
20
  const useSWRConfig = resolveNamed(SWR, 'useSWRConfig');
19
21
  const useSWRMutation = resolveHook(SWRMutation);
20
22
 
21
- if (!useSWR || !useSWRConfig || !useSWRMutation) {
23
+ if (!useSWR || !useSWRInfinite || !useSWRConfig || !useSWRMutation) {
22
24
  throw new Error('Arcway failed to resolve SWR hooks');
23
25
  }
24
26
 
25
- export { useSWR, useSWRConfig, useSWRMutation };
27
+ export { useSWR, useSWRInfinite, useSWRConfig, useSWRMutation };
@@ -0,0 +1,74 @@
1
+ import { useCallback, useMemo, useRef } from 'react';
2
+ import { useApiContext } from '../provider.js';
3
+ import { soloFetch } from '../fetcher.js';
4
+ import { stringifyQuery } from '../query.js';
5
+ import { useSWRConfig, useSWRInfinite } from './swr-compat.js';
6
+
7
+ function buildUrl(base, query) {
8
+ if (!query) return base;
9
+ const qs = stringifyQuery(query);
10
+ return qs ? `${base}?${qs}` : base;
11
+ }
12
+
13
+ export default function useApiPaginated(path, getQuery, options) {
14
+ const { pathPrefix, headers } = useApiContext();
15
+ const { mutate: globalMutate } = useSWRConfig();
16
+ const getQueryRef = useRef(getQuery);
17
+ getQueryRef.current = getQuery;
18
+
19
+ const disabled = path === null || options?.disable === true;
20
+ const fullPath = disabled ? null : `${pathPrefix}${path}`;
21
+ const swrConfig = useMemo(() => {
22
+ const { disable: _disable, ws: _ws, ...rest } = options ?? {};
23
+ return rest;
24
+ }, [options]);
25
+
26
+ const getKey = useCallback(
27
+ (pageIndex, previousPageData) => {
28
+ if (!fullPath) return null;
29
+
30
+ const query =
31
+ typeof getQueryRef.current === 'function'
32
+ ? getQueryRef.current(pageIndex, previousPageData)
33
+ : getQueryRef.current;
34
+
35
+ if (query === null) return null;
36
+ return buildUrl(fullPath, query);
37
+ },
38
+ [fullPath],
39
+ );
40
+
41
+ const result = useSWRInfinite(
42
+ getKey,
43
+ (key) => soloFetch(key, { headers }),
44
+ swrConfig,
45
+ );
46
+
47
+ const makeMethod = useCallback(
48
+ (method) => async (body) => {
49
+ if (!fullPath) throw new Error('Cannot call mutation on a disabled useApiPaginated hook');
50
+
51
+ const res = await soloFetch(fullPath, {
52
+ method,
53
+ headers: {
54
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
55
+ ...headers,
56
+ },
57
+ body: body !== undefined ? JSON.stringify(body) : undefined,
58
+ });
59
+
60
+ await globalMutate((key) => typeof key === 'string' && key.startsWith(fullPath));
61
+ return res;
62
+ },
63
+ [fullPath, headers, globalMutate],
64
+ );
65
+
66
+ return {
67
+ ...result,
68
+ loading: result.isLoading,
69
+ post: makeMethod('POST'),
70
+ put: makeMethod('PUT'),
71
+ patch: makeMethod('PATCH'),
72
+ del: makeMethod('DELETE'),
73
+ };
74
+ }
package/client/index.js CHANGED
@@ -7,6 +7,7 @@ export {
7
7
  useWsManager,
8
8
  } from './provider.js';
9
9
  export { default as useApi } from './hooks/use-api.js';
10
+ export { default as useApiPaginated } from './hooks/use-api-paginated.js';
10
11
  export { default as useQuery } from './hooks/use-query.js';
11
12
  export { default as useMutation } from './hooks/use-mutation.js';
12
13
  export { default as useDebounce } from './hooks/use-debounce.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,11 +47,11 @@
47
47
  "dependencies": {
48
48
  "@aws-sdk/client-s3": "^3.987.0",
49
49
  "@babel/core": "^7.29.0",
50
- "@vitejs/plugin-react": "^5.1.0",
51
50
  "@base-ui/react": "^1.2.0",
52
51
  "@modelcontextprotocol/sdk": "^1.26.0",
53
- "@tailwindcss/vite": "^4.1.18",
54
52
  "@tailwindcss/postcss": "^4.1.18",
53
+ "@tailwindcss/vite": "^4.1.18",
54
+ "@vitejs/plugin-react": "^5.1.0",
55
55
  "arktype": "^2.1.29",
56
56
  "bcryptjs": "^3.0.3",
57
57
  "better-sqlite3": "^12.6.2",
@@ -80,6 +80,7 @@
80
80
  "knex": "^3.1.0",
81
81
  "lucide-react": "^0.564.0",
82
82
  "mailparser": "^3.9.3",
83
+ "ms": "^2.1.3",
83
84
  "nanoid": "^5.1.6",
84
85
  "negotiator": "^1.0.0",
85
86
  "nodemailer": "^8.0.1",
@@ -3,6 +3,7 @@ import { createInfrastructure, destroyInfrastructure } from './infrastructure.js
3
3
  import { EventHandler } from '../events/handler.js';
4
4
  import { JobRunner } from '../jobs/runner.js';
5
5
  import { WorkerPool } from '../jobs/worker-pool.js';
6
+ import { createWorkerData } from '../jobs/worker-config.js';
6
7
  import { loadEnvFiles } from '../env.js';
7
8
  import { McpRouter } from '../mcp/index.js';
8
9
  import WebServer from '../web-server.js';
@@ -16,6 +17,7 @@ import { createWsBackplane } from '../ws/backplane.js';
16
17
  import { runHook } from './hooks.js';
17
18
  import { createViteDevRouter } from '../pages/vite-dev.js';
18
19
  import { resolvePagesOutDir } from '../pages/out-dir.js';
20
+ import { collectRuntimeMeta } from '../meta.js';
19
21
  import path from 'node:path';
20
22
 
21
23
  async function boot(options) {
@@ -27,6 +29,7 @@ async function boot(options) {
27
29
 
28
30
  const infrastructure = await createInfrastructure(config, { runMigrations: true });
29
31
  const { db, redis, queue, cache, files, mail, events, log } = infrastructure;
32
+ const meta = await collectRuntimeMeta(rootDir);
30
33
 
31
34
  if (envFiles.length > 0) log.info('Env files loaded', { envFiles });
32
35
 
@@ -37,7 +40,7 @@ async function boot(options) {
37
40
  // framework services and to the init/ready/shutdown hooks. Mutations from
38
41
  // the init hook (e.g. wrapping `appContext.db`) persist for process lifetime
39
42
  // because downstream consumers read properties off this reference at use time.
40
- const appContext = { db, redis, events, queue, cache, files, mail, log, fileWatcher };
43
+ const appContext = { db, redis, events, queue, cache, files, mail, log, fileWatcher, meta };
41
44
 
42
45
  await runHook('init', appContext, rootDir);
43
46
 
@@ -50,14 +53,14 @@ async function boot(options) {
50
53
  });
51
54
  await eventHandler.init();
52
55
 
53
- // Workers rebuild their own infrastructure from a JSON-safe config snapshot,
54
- // so anything non-serializable on the main-thread config (streams, functions)
55
- // must not ride through workerData. makeConfig already yields plain objects.
56
+ // Workers rebuild their own infrastructure. workerData must stay structured-
57
+ // clone safe, so function-valued app config hooks are stripped from the
58
+ // snapshot and reloaded from arcway.config.js inside worker-entry.
56
59
  const workerPool =
57
60
  config.jobs?.workerPoolSize > 0
58
61
  ? new WorkerPool({
59
62
  size: config.jobs.workerPoolSize,
60
- workerData: { config },
63
+ workerData: createWorkerData({ config, rootDir, mode, meta }),
61
64
  defaultTimeoutMs: config.jobs?.staleTimeoutMs ?? null,
62
65
  })
63
66
  : null;
@@ -70,6 +73,7 @@ async function boot(options) {
70
73
  mail,
71
74
  events,
72
75
  log,
76
+ meta,
73
77
  workerPool,
74
78
  });
75
79
  await jobRunner.init();
@@ -87,7 +91,8 @@ async function boot(options) {
87
91
 
88
92
  const pagesRouter = new PagesRouter(config, { rootDir, log, mode, fileWatcher, appContext });
89
93
  await pagesRouter.init();
90
- const pagesOutDir = resolvePagesOutDir(config, rootDir, mode);
94
+ const resolveCurrentPagesOutDir = () => resolvePagesOutDir(config, rootDir, mode);
95
+ const pagesOutDir = resolveCurrentPagesOutDir();
91
96
  const viteRouter =
92
97
  mode === 'development' && config.pages?.vite?.enabled === true
93
98
  ? await createViteDevRouter({ rootDir, log, config })
@@ -101,6 +106,7 @@ async function boot(options) {
101
106
 
102
107
  const staticRouter = new StaticRouter({
103
108
  outDir: pagesOutDir,
109
+ ...(mode === 'production' ? { resolveOutDir: resolveCurrentPagesOutDir } : {}),
104
110
  publicDir: path.join(rootDir, 'public'),
105
111
  });
106
112
 
package/server/build.js CHANGED
@@ -1,6 +1,48 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
1
3
  import { makeConfig } from './config/loader.js';
2
4
  import { buildPages } from './pages/build.js';
3
- import { resolvePagesOutDir } from './pages/out-dir.js';
5
+ import {
6
+ createPagesVersionedBuildTarget,
7
+ pruneOldPagesBuilds,
8
+ resolvePagesBaseOutDir,
9
+ } from './pages/out-dir.js';
10
+ import { discoverRoutes } from './router/routes.js';
11
+ import { discoverMiddleware } from './router/middleware.js';
12
+ import { EventHandler } from './events/handler.js';
13
+ import { discoverJobs } from './jobs/runner.js';
14
+ import { collectRuntimeMeta } from './meta.js';
15
+
16
+ async function writeBuildMeta(rootDir, meta) {
17
+ const buildDir = path.join(rootDir, '.build');
18
+ const targetPath = path.join(buildDir, 'meta.json');
19
+ const tempPath = path.join(buildDir, `meta.json.next-${process.pid}`);
20
+ await fs.mkdir(buildDir, { recursive: true });
21
+ await fs.writeFile(tempPath, JSON.stringify(meta, null, 2));
22
+ await fs.rename(tempPath, targetPath);
23
+ }
24
+
25
+ async function validateServerModules(config) {
26
+ if (config?.api?.enabled !== false && config?.api?.dir) {
27
+ await discoverRoutes(config.api.dir);
28
+ await discoverMiddleware(config.api.dir);
29
+ }
30
+
31
+ if (config?.events?.dir) {
32
+ const handler = new EventHandler(
33
+ { dir: config.events.dir },
34
+ {
35
+ events: { subscribe() {} },
36
+ log: { info() {}, warn() {}, error() {}, extend() { return this; } },
37
+ },
38
+ );
39
+ await handler.init();
40
+ }
41
+
42
+ if (config?.jobs?.enabled !== false && config?.jobs?.dir) {
43
+ await discoverJobs(config.jobs.dir);
44
+ }
45
+ }
4
46
 
5
47
  async function build(options) {
6
48
  const rootDir = options?.rootDir ?? process.cwd();
@@ -10,16 +52,32 @@ async function build(options) {
10
52
  let pages;
11
53
  const pagesEnabled = config?.pages?.enabled !== false;
12
54
  if (pagesEnabled) {
55
+ const baseOutDir = resolvePagesBaseOutDir(config, rootDir, 'production');
56
+ const target = await createPagesVersionedBuildTarget(baseOutDir);
13
57
  pages = await buildPages({
14
58
  rootDir,
15
- outDir: resolvePagesOutDir(config, rootDir, 'production'),
59
+ outDir: target.outDir,
16
60
  serverTarget: buildTarget,
17
61
  minify: true,
18
62
  fonts: config?.pages?.fonts,
19
63
  });
64
+ await pruneOldPagesBuilds(baseOutDir, config?.pages?.retainBuilds ?? 5);
65
+ pages = {
66
+ ...pages,
67
+ baseOutDir,
68
+ buildHash: target.buildHash,
69
+ outDir: target.outDir,
70
+ };
20
71
  }
21
72
 
22
- return { pages };
73
+ await validateServerModules(config);
74
+ const meta = await collectRuntimeMeta(rootDir);
75
+ await writeBuildMeta(rootDir, {
76
+ ...meta,
77
+ ...(pages?.buildHash ? { pagesBuildHash: pages.buildHash } : {}),
78
+ });
79
+
80
+ return { pages, serverValidated: true, meta };
23
81
  }
24
82
 
25
- export { build };
83
+ export { build, validateServerModules };
@@ -1,5 +1,6 @@
1
1
  import MemoryCacheDriver from './drivers/memory.js';
2
2
  import RedisCacheDriver from './drivers/redis.js';
3
+ import { normalizeCacheTtl } from './ttl.js';
3
4
 
4
5
  class Cache {
5
6
  _driver;
@@ -9,6 +10,10 @@ class Cache {
9
10
  constructor(config, { redis, log } = {}) {
10
11
  this._log = log;
11
12
  this._namespace = config?.namespace ?? '';
13
+ this._defaultTtlMs =
14
+ config?.defaultTtlMs === undefined
15
+ ? undefined
16
+ : normalizeCacheTtl(config.defaultTtlMs, 'cache.defaultTtlMs');
12
17
 
13
18
  if (config?.driver === 'redis') {
14
19
  this._driver = new RedisCacheDriver(redis.client, redis.keyPrefix);
@@ -35,7 +40,9 @@ class Cache {
35
40
  }
36
41
 
37
42
  async set(key, value, ttlMs) {
38
- await this._driver.set(this._namespace, key, JSON.stringify(value), ttlMs);
43
+ const resolvedTtlMs =
44
+ ttlMs === undefined ? this._defaultTtlMs : normalizeCacheTtl(ttlMs, 'cache ttl');
45
+ await this._driver.set(this._namespace, key, JSON.stringify(value), resolvedTtlMs);
39
46
  }
40
47
 
41
48
  async delete(key) {
@@ -52,7 +59,9 @@ class Cache {
52
59
  }
53
60
  }
54
61
  const value = await fn();
55
- await this._driver.set(this._namespace, key, JSON.stringify(value), ttlMs);
62
+ const resolvedTtlMs =
63
+ ttlMs === undefined ? this._defaultTtlMs : normalizeCacheTtl(ttlMs, 'cache ttl');
64
+ await this._driver.set(this._namespace, key, JSON.stringify(value), resolvedTtlMs);
56
65
  return value;
57
66
  }
58
67
 
@@ -62,6 +71,7 @@ class Cache {
62
71
  child._driver = this._driver;
63
72
  child._log = this._log;
64
73
  child._namespace = namespace;
74
+ child._defaultTtlMs = this._defaultTtlMs;
65
75
  return child;
66
76
  }
67
77
  }
@@ -0,0 +1,24 @@
1
+ import { parseDuration } from '../config/duration.js'
2
+
3
+ function parseCacheTtl(value) {
4
+ if (value === undefined || value === null) return null
5
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null
6
+ if (typeof value !== 'string') return null
7
+
8
+ const normalized = value.trim()
9
+ if (!normalized) return null
10
+
11
+ return parseDuration(normalized)
12
+ }
13
+
14
+ function normalizeCacheTtl(value, label = 'cache ttl') {
15
+ const parsed = parseCacheTtl(value)
16
+ if (parsed === null) {
17
+ throw new Error(
18
+ `Invalid ${label}: ${JSON.stringify(value)}. Use milliseconds as a number or a string like "500ms", "30s", "15m", "2h", or "7d".`,
19
+ )
20
+ }
21
+ return parsed
22
+ }
23
+
24
+ export { parseCacheTtl, normalizeCacheTtl }
@@ -0,0 +1,49 @@
1
+ import ms from 'ms'
2
+
3
+ function parseDuration(value) {
4
+ if (value === undefined || value === null) return null
5
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null
6
+ if (typeof value !== 'string') return null
7
+
8
+ const normalized = value.trim()
9
+ if (!normalized) return null
10
+
11
+ const parsed = ms(normalized)
12
+ return typeof parsed === 'number' ? parsed : null
13
+ }
14
+
15
+ function normalizeDurationMs(value, label) {
16
+ const parsed = parseDuration(value)
17
+ if (parsed === null) {
18
+ throw new Error(
19
+ `Invalid ${label}: ${JSON.stringify(value)}. Use milliseconds as a number or a string like "500ms", "30s", "15m", "2h", or "7d".`,
20
+ )
21
+ }
22
+ return parsed
23
+ }
24
+
25
+ function normalizeDurationSeconds(value, label) {
26
+ if (value === 0) return 0
27
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null
28
+ const parsed = parseDuration(value)
29
+ if (parsed === null) {
30
+ throw new Error(
31
+ `Invalid ${label}: ${JSON.stringify(value)}. Use seconds as a number or a string like "30s", "15m", "2h", or "7d".`,
32
+ )
33
+ }
34
+ return Math.floor(parsed / 1000)
35
+ }
36
+
37
+ function normalizeDurationDays(value, label) {
38
+ if (value === null) return null
39
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null
40
+ const parsed = parseDuration(value)
41
+ if (parsed === null) {
42
+ throw new Error(
43
+ `Invalid ${label}: ${JSON.stringify(value)}. Use days as a number or a string like "7d", "2w", or "168h".`,
44
+ )
45
+ }
46
+ return Math.floor(parsed / 86_400_000)
47
+ }
48
+
49
+ export { parseDuration, normalizeDurationMs, normalizeDurationSeconds, normalizeDurationDays }
@@ -1,10 +1,16 @@
1
+ import { normalizeCacheTtl } from '../../cache/ttl.js';
2
+
1
3
  const DEFAULTS = {
2
4
  driver: 'memory',
3
5
  };
4
6
 
5
7
  function resolve(config) {
6
8
  if (!config.cache) return config;
7
- return { ...config, cache: { ...DEFAULTS, ...config.cache } };
9
+ const resolved = { ...DEFAULTS, ...config.cache };
10
+ if (resolved.defaultTtlMs !== undefined) {
11
+ resolved.defaultTtlMs = normalizeCacheTtl(resolved.defaultTtlMs, 'cache.defaultTtlMs');
12
+ }
13
+ return { ...config, cache: resolved };
8
14
  }
9
15
 
10
16
  export default resolve;
@@ -1,5 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
+ import { normalizeDurationMs } from '../duration.js';
3
4
 
4
5
  // Leave one core free for the main thread (dispatch/polling/HTTP). 0 disables
5
6
  // worker threads entirely and runs handlers inline on the main thread.
@@ -20,9 +21,21 @@ const DEFAULTS = {
20
21
 
21
22
  function resolve(config, { rootDir } = {}) {
22
23
  const jobs = { ...DEFAULTS, ...config.jobs };
23
- if (jobs.dir && !path.isAbsolute(jobs.dir)) {
24
+ if (rootDir && jobs.dir && !path.isAbsolute(jobs.dir)) {
24
25
  jobs.dir = path.resolve(rootDir, jobs.dir);
25
26
  }
27
+ if (jobs.backoffMs !== undefined) {
28
+ jobs.backoffMs = normalizeDurationMs(jobs.backoffMs, 'jobs.backoffMs');
29
+ }
30
+ if (jobs.pollIntervalMs !== undefined) {
31
+ jobs.pollIntervalMs = normalizeDurationMs(jobs.pollIntervalMs, 'jobs.pollIntervalMs');
32
+ }
33
+ if (jobs.cooldownMs !== undefined) {
34
+ jobs.cooldownMs = normalizeDurationMs(jobs.cooldownMs, 'jobs.cooldownMs');
35
+ }
36
+ if (jobs.staleTimeoutMs !== undefined) {
37
+ jobs.staleTimeoutMs = normalizeDurationMs(jobs.staleTimeoutMs, 'jobs.staleTimeoutMs');
38
+ }
26
39
  if (jobs.workerPoolSize === undefined) jobs.workerPoolSize = defaultWorkerPoolSize();
27
40
  return { ...config, jobs };
28
41
  }
@@ -1,3 +1,5 @@
1
+ import { normalizeDurationDays, normalizeDurationSeconds } from '../duration.js';
2
+
1
3
  const DEFAULTS = {
2
4
  driver: 'console',
3
5
  enabled: true,
@@ -5,7 +7,20 @@ const DEFAULTS = {
5
7
 
6
8
  function resolve(config) {
7
9
  if (!config.mail) return config;
8
- return { ...config, mail: { ...DEFAULTS, ...config.mail } };
10
+ const mail = { ...DEFAULTS, ...config.mail };
11
+ if (mail.inbound?.imap?.pollIntervalSeconds !== undefined) {
12
+ mail.inbound.imap.pollIntervalSeconds = normalizeDurationSeconds(
13
+ mail.inbound.imap.pollIntervalSeconds,
14
+ 'mail.inbound.imap.pollIntervalSeconds',
15
+ );
16
+ }
17
+ if (mail.inbound?.retentionDays !== undefined) {
18
+ mail.inbound.retentionDays = normalizeDurationDays(
19
+ mail.inbound.retentionDays,
20
+ 'mail.inbound.retentionDays',
21
+ );
22
+ }
23
+ return { ...config, mail };
9
24
  }
10
25
 
11
26
  export default resolve;
@@ -5,6 +5,7 @@ const DEFAULTS = {
5
5
  dir: 'pages',
6
6
  outDir: '.build/pages',
7
7
  devOutDir: '.build/dev/pages',
8
+ retainBuilds: 5,
8
9
  vite: {
9
10
  enabled: true,
10
11
  },
@@ -1,10 +1,16 @@
1
+ import { normalizeDurationMs } from '../duration.js';
2
+
1
3
  const DEFAULTS = {
2
4
  tableName: 'arcway_queue',
3
5
  lockCooldownMs: 5 * 60 * 1000,
4
6
  };
5
7
 
6
8
  function resolve(config) {
7
- return { ...config, queue: { ...DEFAULTS, ...config.queue } };
9
+ const queue = { ...DEFAULTS, ...config.queue };
10
+ if (queue.lockCooldownMs !== undefined) {
11
+ queue.lockCooldownMs = normalizeDurationMs(queue.lockCooldownMs, 'queue.lockCooldownMs');
12
+ }
13
+ return { ...config, queue };
8
14
  }
9
15
 
10
16
  export default resolve;
@@ -1,3 +1,5 @@
1
+ import { normalizeDurationMs, normalizeDurationSeconds } from '../duration.js';
2
+
1
3
  const DEFAULTS = {
2
4
  host: '0.0.0.0',
3
5
  port: 3000,
@@ -19,11 +21,21 @@ function resolveCors(config, mode) {
19
21
  if (config === false) return void 0;
20
22
  if (config === void 0 && mode === 'production') return void 0;
21
23
  const userConfig = typeof config === 'object' ? config : {};
22
- return { ...DEFAULT_CORS, ...userConfig };
24
+ const resolved = { ...DEFAULT_CORS, ...userConfig };
25
+ if (resolved.maxAge !== undefined) {
26
+ resolved.maxAge = normalizeDurationSeconds(resolved.maxAge, 'server.cors.maxAge');
27
+ }
28
+ return resolved;
23
29
  }
24
30
 
25
31
  function resolve(config, { mode } = {}) {
26
32
  const server = { ...DEFAULTS, ...config.server };
33
+ if (server.shutdownTimeoutMs !== undefined) {
34
+ server.shutdownTimeoutMs = normalizeDurationMs(
35
+ server.shutdownTimeoutMs,
36
+ 'server.shutdownTimeoutMs',
37
+ );
38
+ }
27
39
  server.cors = resolveCors(server.cors, mode);
28
40
  return { ...config, server };
29
41
  }
@@ -1,3 +1,5 @@
1
+ import { normalizeDurationMs } from '../duration.js';
2
+
1
3
  const DEFAULTS = {
2
4
  path: '/ws',
3
5
  pingIntervalMs: 30000,
@@ -8,6 +10,12 @@ const VALID_DRIVERS = new Set(['memory', 'redis']);
8
10
 
9
11
  function resolve(config) {
10
12
  const websocket = { ...DEFAULTS, ...config.websocket };
13
+ if (websocket.pingIntervalMs !== undefined) {
14
+ websocket.pingIntervalMs = normalizeDurationMs(
15
+ websocket.pingIntervalMs,
16
+ 'websocket.pingIntervalMs',
17
+ );
18
+ }
11
19
  if (!VALID_DRIVERS.has(websocket.driver)) {
12
20
  throw new Error(
13
21
  `Invalid config: websocket.driver="${websocket.driver}" must be one of: ${[...VALID_DRIVERS].join(', ')}`,
package/server/context.js CHANGED
@@ -1,3 +1,6 @@
1
+ import * as vault from './lib/vault/index.js';
2
+ import { wsBroadcastToPath, wsSendToSocket } from './ws/registry.js';
3
+
1
4
  function trackDb(db) {
2
5
  if (!db || typeof db !== 'function' && typeof db !== 'object') return { db, stats: { queries: 0, durationMs: 0 } };
3
6
  const stats = { queries: 0, durationMs: 0 };
@@ -58,8 +61,60 @@ function wrapBuilder(builder, stats) {
58
61
  return builder;
59
62
  }
60
63
 
64
+ function buildErrResponse(input, status = 400) {
65
+ if (typeof input === 'string') {
66
+ return { status, error: { message: input } };
67
+ }
68
+ if (input instanceof Error) {
69
+ return { status, error: { message: input.message } };
70
+ }
71
+ if (input && typeof input === 'object') {
72
+ const { status: explicitStatus, ...error } = input;
73
+ return { status: explicitStatus ?? status, error };
74
+ }
75
+ return { status, error: { message: 'An error occurred' } };
76
+ }
77
+
78
+ function normalizeEvent(event) {
79
+ if (event === undefined) return undefined;
80
+ return {
81
+ name: event?.name ?? null,
82
+ payload: Object.prototype.hasOwnProperty.call(event || {}, 'payload')
83
+ ? (event.payload ?? null)
84
+ : null,
85
+ };
86
+ }
87
+
88
+ function normalizeMeta(meta) {
89
+ return {
90
+ builtAt: meta?.builtAt ?? null,
91
+ version: meta?.version ?? null,
92
+ buildNumber: meta?.buildNumber ?? null,
93
+ branch: meta?.branch ?? null,
94
+ tag: meta?.tag ?? null,
95
+ dirty: meta?.dirty ?? null,
96
+ environment: meta?.environment ?? process.env.NODE_ENV ?? 'development',
97
+ nodeVersion: meta?.nodeVersion ?? process.version,
98
+ shortSha: meta?.shortSha ?? null,
99
+ sha: meta?.sha ?? null,
100
+ packageVersion: meta?.packageVersion ?? null,
101
+ };
102
+ }
103
+
61
104
  function buildContext(appContext, extras) {
62
105
  const tracked = trackDb(appContext.db);
106
+ const normalizedExtras = { ...extras };
107
+ if (Object.prototype.hasOwnProperty.call(normalizedExtras, 'event')) {
108
+ normalizedExtras.event = normalizeEvent(normalizedExtras.event);
109
+ }
110
+ const req = normalizedExtras.req;
111
+ const ws =
112
+ req?.socketId && req?.path
113
+ ? {
114
+ send: (response) => wsSendToSocket(req.socketId, req.path, response),
115
+ broadcast: (response) => wsBroadcastToPath(req.path, response),
116
+ }
117
+ : null;
63
118
  const ctx = {
64
119
  db: tracked.db,
65
120
  log: appContext.log,
@@ -68,10 +123,14 @@ function buildContext(appContext, extras) {
68
123
  queue: appContext.queue,
69
124
  files: appContext.files,
70
125
  mail: appContext.mail,
126
+ meta: normalizeMeta(appContext.meta),
127
+ vault,
128
+ ws,
129
+ err: (input, status) => buildErrResponse(input, status),
71
130
  _dbStats: tracked.stats,
72
- ...extras,
131
+ ...normalizedExtras,
73
132
  };
74
133
  return ctx;
75
134
  }
76
135
 
77
- export { buildContext };
136
+ export { buildContext, buildErrResponse };