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.
- package/client/fetcher.js +4 -1
- package/client/hooks/swr-compat.js +4 -2
- package/client/hooks/use-api-paginated.js +74 -0
- package/client/index.js +1 -0
- package/package.json +4 -3
- package/server/boot/index.js +12 -6
- package/server/build.js +62 -4
- package/server/cache/index.js +12 -2
- package/server/cache/ttl.js +24 -0
- package/server/config/duration.js +49 -0
- package/server/config/modules/cache.js +7 -1
- package/server/config/modules/jobs.js +14 -1
- package/server/config/modules/mail.js +16 -1
- package/server/config/modules/pages.js +1 -0
- package/server/config/modules/queue.js +7 -1
- package/server/config/modules/server.js +13 -1
- package/server/config/modules/websocket.js +8 -0
- package/server/context.js +61 -2
- package/server/db/index.js +6 -1
- package/server/events/drivers/memory.js +22 -4
- package/server/events/drivers/redis.js +20 -5
- package/server/events/handler.js +21 -7
- package/server/events/index.js +2 -2
- package/server/index.js +0 -1
- package/server/jobs/runner.js +3 -2
- package/server/jobs/worker-config.js +58 -0
- package/server/jobs/worker-entry.js +7 -12
- package/server/meta.js +106 -0
- package/server/pages/build-plugins.js +2 -1
- package/server/pages/handler.js +23 -5
- package/server/pages/out-dir.js +103 -2
- package/server/pages/pages-router.js +3 -2
- package/server/pages/ssr.js +55 -18
- package/server/server.js +13 -1
- package/server/session/index.js +5 -1
- package/server/static/index.js +5 -2
- package/server/web-server.js +3 -3
- package/server/ws/realtime.js +24 -8
- 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.
|
|
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",
|
package/server/boot/index.js
CHANGED
|
@@ -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
|
|
54
|
-
// so
|
|
55
|
-
//
|
|
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
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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 };
|
package/server/cache/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
131
|
+
...normalizedExtras,
|
|
73
132
|
};
|
|
74
133
|
return ctx;
|
|
75
134
|
}
|
|
76
135
|
|
|
77
|
-
export { buildContext };
|
|
136
|
+
export { buildContext, buildErrResponse };
|