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/server/db/index.js
CHANGED
|
@@ -43,7 +43,9 @@ async function createDB(config, { log } = {}) {
|
|
|
43
43
|
...hooks,
|
|
44
44
|
client,
|
|
45
45
|
connection,
|
|
46
|
-
...(isSqlite
|
|
46
|
+
...(isSqlite
|
|
47
|
+
? { useNullAsDefault: config.sqlite?.useNullAsDefault ?? config.useNullAsDefault ?? true }
|
|
48
|
+
: {}),
|
|
47
49
|
});
|
|
48
50
|
|
|
49
51
|
try {
|
|
@@ -55,6 +57,9 @@ async function createDB(config, { log } = {}) {
|
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
if (isSqlite) {
|
|
60
|
+
if (config.sqlite?.foreignKeys) {
|
|
61
|
+
await db.raw('PRAGMA foreign_keys = ON');
|
|
62
|
+
}
|
|
58
63
|
await db.raw('PRAGMA journal_mode = WAL');
|
|
59
64
|
await db.raw('PRAGMA busy_timeout = 5000');
|
|
60
65
|
await db.raw('PRAGMA synchronous = NORMAL');
|
|
@@ -1,22 +1,40 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { patternToRegex } from '../pattern.js';
|
|
2
3
|
class MemoryTransport {
|
|
3
4
|
subscriptions = [];
|
|
4
5
|
/** Subscribe to events matching a pattern. */
|
|
5
|
-
subscribe(pattern, handler) {
|
|
6
|
+
subscribe(pattern, handler, options = {}) {
|
|
6
7
|
const regex = patternToRegex(pattern);
|
|
7
|
-
|
|
8
|
+
const subscription = { pattern, regex, handler, consumerGroup: options.consumerGroup };
|
|
9
|
+
this.subscriptions.push(subscription);
|
|
10
|
+
return () => {
|
|
11
|
+
const index = this.subscriptions.indexOf(subscription);
|
|
12
|
+
if (index !== -1) this.subscriptions.splice(index, 1);
|
|
13
|
+
};
|
|
8
14
|
}
|
|
9
15
|
/** Emit an event. Handlers run asynchronously — fire-and-forget. */
|
|
10
16
|
async emit(eventName, payload) {
|
|
11
17
|
const matching = this.subscriptions.filter((sub) => sub.regex.test(eventName));
|
|
12
18
|
if (matching.length === 0) return;
|
|
19
|
+
const meta = { id: randomUUID() };
|
|
20
|
+
const grouped = [];
|
|
21
|
+
const seenGroups = new Set();
|
|
22
|
+
for (const sub of matching) {
|
|
23
|
+
if (!sub.consumerGroup) {
|
|
24
|
+
grouped.push(sub);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (seenGroups.has(sub.consumerGroup)) continue;
|
|
28
|
+
seenGroups.add(sub.consumerGroup);
|
|
29
|
+
grouped.push(sub);
|
|
30
|
+
}
|
|
13
31
|
Promise.allSettled(
|
|
14
|
-
|
|
32
|
+
grouped.map((sub) => sub.handler(payload, eventName, meta)),
|
|
15
33
|
).then((results) => {
|
|
16
34
|
for (let i = 0; i < results.length; i++) {
|
|
17
35
|
if (results[i].status === 'rejected') {
|
|
18
36
|
console.error(
|
|
19
|
-
`Event listener error [${
|
|
37
|
+
`Event listener error [${grouped[i].pattern}] for event "${eventName}":`,
|
|
20
38
|
results[i].reason,
|
|
21
39
|
);
|
|
22
40
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { patternToRegex } from '../pattern.js';
|
|
2
3
|
class RedisTransport {
|
|
3
4
|
pub;
|
|
@@ -10,17 +11,22 @@ class RedisTransport {
|
|
|
10
11
|
this.sub = sub;
|
|
11
12
|
this.prefix = `${keyPrefix}events:`;
|
|
12
13
|
}
|
|
13
|
-
subscribe(pattern, handler) {
|
|
14
|
+
subscribe(pattern, handler, options = {}) {
|
|
14
15
|
const regex = patternToRegex(pattern);
|
|
15
|
-
|
|
16
|
+
const subscription = { pattern, regex, handler, consumerGroup: options.consumerGroup };
|
|
17
|
+
this.subscriptions.push(subscription);
|
|
16
18
|
if (!this.listening) {
|
|
17
19
|
this.listening = true;
|
|
18
20
|
this.startListening();
|
|
19
21
|
}
|
|
22
|
+
return () => {
|
|
23
|
+
const index = this.subscriptions.indexOf(subscription);
|
|
24
|
+
if (index !== -1) this.subscriptions.splice(index, 1);
|
|
25
|
+
};
|
|
20
26
|
}
|
|
21
27
|
async emit(eventName, payload) {
|
|
22
28
|
const channel = `${this.prefix}${eventName}`;
|
|
23
|
-
const message = JSON.stringify({ event: eventName, payload });
|
|
29
|
+
const message = JSON.stringify({ id: randomUUID(), event: eventName, payload });
|
|
24
30
|
await this.pub.publish(channel, message);
|
|
25
31
|
}
|
|
26
32
|
startListening() {
|
|
@@ -36,10 +42,19 @@ class RedisTransport {
|
|
|
36
42
|
console.warn('[RedisTransport] Malformed event message, skipping');
|
|
37
43
|
return;
|
|
38
44
|
}
|
|
39
|
-
const { event: eventName, payload } = parsed;
|
|
45
|
+
const { id, event: eventName, payload } = parsed;
|
|
40
46
|
const matching = this.subscriptions.filter((sub) => sub.regex.test(eventName));
|
|
41
47
|
if (matching.length === 0) return;
|
|
42
|
-
Promise.allSettled(
|
|
48
|
+
Promise.allSettled(
|
|
49
|
+
matching.map(async (sub) => {
|
|
50
|
+
if (sub.consumerGroup) {
|
|
51
|
+
const claimKey = `${this.prefix}consumer-groups:${sub.consumerGroup}:${id}`;
|
|
52
|
+
const claimed = await this.pub.set(claimKey, '1', 'PX', 60 * 60 * 1000, 'NX');
|
|
53
|
+
if (claimed !== 'OK') return;
|
|
54
|
+
}
|
|
55
|
+
await sub.handler(payload, eventName, { id });
|
|
56
|
+
}),
|
|
57
|
+
).then((results) => {
|
|
43
58
|
for (let i = 0; i < results.length; i++) {
|
|
44
59
|
if (results[i].status === 'rejected') {
|
|
45
60
|
console.error(
|
package/server/events/handler.js
CHANGED
|
@@ -11,8 +11,10 @@ function listenerPathToEvent(relativePath) {
|
|
|
11
11
|
|
|
12
12
|
function validateHandler(item, filePath, index) {
|
|
13
13
|
const label = index !== undefined ? `[${index}]` : '';
|
|
14
|
-
if (typeof item === 'function') return item;
|
|
15
|
-
if (typeof item === 'object' && item !== null && typeof item.handler === 'function')
|
|
14
|
+
if (typeof item === 'function') return { handler: item };
|
|
15
|
+
if (typeof item === 'object' && item !== null && typeof item.handler === 'function') {
|
|
16
|
+
return { handler: item.handler, consumerGroup: item.consumerGroup };
|
|
17
|
+
}
|
|
16
18
|
throw new Error(
|
|
17
19
|
`Listener at ${filePath}${label} must be a function or an object with a "handler" function`,
|
|
18
20
|
);
|
|
@@ -54,11 +56,23 @@ class EventHandler {
|
|
|
54
56
|
|
|
55
57
|
const items = Array.isArray(exported) ? exported : [exported];
|
|
56
58
|
for (let i = 0; i < items.length; i++) {
|
|
57
|
-
const handler = validateHandler(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
const { handler, consumerGroup } = validateHandler(
|
|
60
|
+
items[i],
|
|
61
|
+
filePath,
|
|
62
|
+
items.length > 1 ? i : undefined,
|
|
63
|
+
);
|
|
64
|
+
const groupName =
|
|
65
|
+
consumerGroup === false
|
|
66
|
+
? undefined
|
|
67
|
+
: consumerGroup ?? `listener:${relativePath}:${items.length > 1 ? i : 0}`;
|
|
68
|
+
this._events.subscribe(
|
|
69
|
+
event,
|
|
70
|
+
(payload, _eventName) => {
|
|
71
|
+
const ctx = buildContext(this._appContext, { event: { name: _eventName, payload } });
|
|
72
|
+
return handler(ctx);
|
|
73
|
+
},
|
|
74
|
+
{ consumerGroup: groupName },
|
|
75
|
+
);
|
|
62
76
|
this._listeners.push({ event, fileName: relativePath });
|
|
63
77
|
}
|
|
64
78
|
this._log?.info(` ${relativePath} → ${event}${items.length > 1 ? ` (${items.length} listeners)` : ''}`);
|
package/server/events/index.js
CHANGED
|
@@ -18,8 +18,8 @@ class Events {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
subscribe(pattern, handler) {
|
|
22
|
-
this._transport.subscribe(pattern, handler);
|
|
21
|
+
subscribe(pattern, handler, options) {
|
|
22
|
+
return this._transport.subscribe(pattern, handler, options);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
async emit(eventName, payload) {
|
package/server/index.js
CHANGED
package/server/jobs/runner.js
CHANGED
|
@@ -45,7 +45,7 @@ class JobRunner {
|
|
|
45
45
|
_stopped = false;
|
|
46
46
|
_lastEnqueuedMinute = new Map();
|
|
47
47
|
|
|
48
|
-
constructor(config, { db, queue, cache, files, mail, events, log, workerPool } = {}) {
|
|
48
|
+
constructor(config, { db, queue, cache, files, mail, events, log, meta, workerPool } = {}) {
|
|
49
49
|
this._config = config;
|
|
50
50
|
this._log = log;
|
|
51
51
|
this._dispatcher = new JobDispatcher({
|
|
@@ -53,7 +53,7 @@ class JobRunner {
|
|
|
53
53
|
staleTimeoutMs: config?.staleTimeoutMs,
|
|
54
54
|
workerPool,
|
|
55
55
|
});
|
|
56
|
-
this._appContext = { db, queue, cache, files, mail, events, log };
|
|
56
|
+
this._appContext = { db, queue, cache, files, mail, events, log, meta };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
get dispatcher() {
|
|
@@ -96,6 +96,7 @@ class JobRunner {
|
|
|
96
96
|
cache: this._appContext.cache?.withNamespace(SYSTEM_JOB_DOMAIN),
|
|
97
97
|
files: this._appContext.files?.withNamespace(SYSTEM_JOB_DOMAIN),
|
|
98
98
|
mail: this._appContext.mail,
|
|
99
|
+
meta: this._appContext.meta,
|
|
99
100
|
log: this._log.extend({ logger: SYSTEM_JOB_DOMAIN }),
|
|
100
101
|
};
|
|
101
102
|
const systemJobs = registerSystemJobs(this._config, this._dispatcher, systemContext);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { makeConfig } from '../config/loader.js';
|
|
2
|
+
|
|
3
|
+
function isPlainObject(value) {
|
|
4
|
+
return (
|
|
5
|
+
value !== null &&
|
|
6
|
+
typeof value === 'object' &&
|
|
7
|
+
!Array.isArray(value) &&
|
|
8
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function stripFunctions(value) {
|
|
13
|
+
if (typeof value === 'function') return undefined;
|
|
14
|
+
if (Array.isArray(value)) return value.map(stripFunctions);
|
|
15
|
+
if (!isPlainObject(value)) return value;
|
|
16
|
+
|
|
17
|
+
const result = {};
|
|
18
|
+
for (const [key, child] of Object.entries(value)) {
|
|
19
|
+
const stripped = stripFunctions(child);
|
|
20
|
+
if (stripped !== undefined) result[key] = stripped;
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mergeConfig(base, override) {
|
|
26
|
+
if (!isPlainObject(base) || !isPlainObject(override)) {
|
|
27
|
+
return override === undefined ? base : override;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const result = { ...base };
|
|
31
|
+
for (const [key, value] of Object.entries(override)) {
|
|
32
|
+
result[key] = mergeConfig(result[key], value);
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createWorkerData({ config, rootDir, mode, meta } = {}) {
|
|
38
|
+
return {
|
|
39
|
+
config: stripFunctions(config),
|
|
40
|
+
configRootDir: rootDir,
|
|
41
|
+
mode,
|
|
42
|
+
meta: stripFunctions(meta),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function resolveWorkerConfig(workerData) {
|
|
47
|
+
if (!workerData?.configRootDir) {
|
|
48
|
+
if (!workerData?.config) {
|
|
49
|
+
throw new Error('Worker started without workerData.config — cannot build job context');
|
|
50
|
+
}
|
|
51
|
+
return workerData.config;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const appConfig = await makeConfig(workerData.configRootDir, { mode: workerData.mode });
|
|
55
|
+
return mergeConfig(appConfig, workerData.config);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { createWorkerData, mergeConfig, resolveWorkerConfig, stripFunctions };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
3
|
import { serializeError } from './worker-pool.js';
|
|
4
|
+
import { resolveWorkerConfig } from './worker-config.js';
|
|
4
5
|
import { buildContext } from '../context.js';
|
|
5
6
|
import { createInfrastructure, destroyInfrastructure } from '../boot/infrastructure.js';
|
|
6
7
|
|
|
@@ -21,16 +22,12 @@ let shuttingDown = false;
|
|
|
21
22
|
async function ensureInfrastructure() {
|
|
22
23
|
if (infrastructure) return infrastructure;
|
|
23
24
|
if (!infrastructurePromise) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
infrastructurePromise = createInfrastructure(config, { runMigrations: false }).then(
|
|
29
|
-
(services) => {
|
|
25
|
+
infrastructurePromise = resolveWorkerConfig(workerData)
|
|
26
|
+
.then((config) => createInfrastructure(config, { runMigrations: false }))
|
|
27
|
+
.then((services) => {
|
|
30
28
|
infrastructure = services;
|
|
31
29
|
return services;
|
|
32
|
-
}
|
|
33
|
-
);
|
|
30
|
+
});
|
|
34
31
|
}
|
|
35
32
|
return infrastructurePromise;
|
|
36
33
|
}
|
|
@@ -55,9 +52,7 @@ async function loadHandler(handlerPath, handlerExport) {
|
|
|
55
52
|
? exported.handler
|
|
56
53
|
: null;
|
|
57
54
|
if (!fn) {
|
|
58
|
-
throw new Error(
|
|
59
|
-
`Worker handler export "${exportName}" in ${handlerPath} is not a function`,
|
|
60
|
-
);
|
|
55
|
+
throw new Error(`Worker handler export "${exportName}" in ${handlerPath} is not a function`);
|
|
61
56
|
}
|
|
62
57
|
return fn;
|
|
63
58
|
}
|
|
@@ -71,7 +66,7 @@ async function runTask(task) {
|
|
|
71
66
|
// and any future cpu-only caller) gets the raw payload — no DB spin-up.
|
|
72
67
|
if (withContext) {
|
|
73
68
|
const services = await ensureInfrastructure();
|
|
74
|
-
const ctx = buildContext(services, { payload });
|
|
69
|
+
const ctx = buildContext({ ...services, meta: workerData?.meta }, { payload });
|
|
75
70
|
return fn(ctx);
|
|
76
71
|
}
|
|
77
72
|
return fn(payload);
|
package/server/meta.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
function runGit(rootDir, args) {
|
|
6
|
+
try {
|
|
7
|
+
return execFileSync('git', args, {
|
|
8
|
+
cwd: rootDir,
|
|
9
|
+
encoding: 'utf8',
|
|
10
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
11
|
+
}).trim();
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeMeta(meta = {}) {
|
|
18
|
+
const sha = meta.sha ?? null;
|
|
19
|
+
const shortSha = meta.shortSha ?? (sha ? sha.slice(0, 7) : null);
|
|
20
|
+
const packageVersion = meta.packageVersion ?? null;
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
builtAt: meta.builtAt ?? null,
|
|
24
|
+
version: meta.version ?? shortSha ?? packageVersion,
|
|
25
|
+
buildNumber: meta.buildNumber ?? null,
|
|
26
|
+
branch: meta.branch ?? null,
|
|
27
|
+
tag: meta.tag ?? null,
|
|
28
|
+
dirty: meta.dirty ?? null,
|
|
29
|
+
environment: meta.environment ?? process.env.NODE_ENV ?? 'development',
|
|
30
|
+
nodeVersion: meta.nodeVersion ?? process.version,
|
|
31
|
+
shortSha,
|
|
32
|
+
sha,
|
|
33
|
+
packageVersion,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveEnvSha() {
|
|
38
|
+
return (
|
|
39
|
+
process.env.GIT_COMMIT ||
|
|
40
|
+
process.env.GITHUB_SHA ||
|
|
41
|
+
process.env.VERCEL_GIT_COMMIT_SHA ||
|
|
42
|
+
process.env.CI_COMMIT_SHA ||
|
|
43
|
+
null
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveGitSha(rootDir) {
|
|
48
|
+
const envSha = resolveEnvSha();
|
|
49
|
+
if (envSha) return envSha.trim();
|
|
50
|
+
return runGit(rootDir, ['rev-parse', 'HEAD']);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveBuildNumber(rootDir) {
|
|
54
|
+
const count = runGit(rootDir, ['rev-list', '--count', 'HEAD']);
|
|
55
|
+
if (!count) return null;
|
|
56
|
+
|
|
57
|
+
const parsed = Number(count);
|
|
58
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveGitBranch(rootDir) {
|
|
62
|
+
return runGit(rootDir, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveGitTag(rootDir) {
|
|
66
|
+
const tag = runGit(rootDir, ['describe', '--tags', '--abbrev=0']);
|
|
67
|
+
return tag || null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveDirty(rootDir) {
|
|
71
|
+
const status = runGit(rootDir, ['status', '--porcelain']);
|
|
72
|
+
if (status === null) return null;
|
|
73
|
+
return status.length > 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function resolvePackageVersion(rootDir) {
|
|
77
|
+
try {
|
|
78
|
+
const pkg = JSON.parse(await fs.readFile(path.join(rootDir, 'package.json'), 'utf8'));
|
|
79
|
+
return pkg.version ?? null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function collectRuntimeMeta(rootDir, { builtAt = new Date().toISOString() } = {}) {
|
|
86
|
+
const sha = resolveGitSha(rootDir);
|
|
87
|
+
const buildNumber = resolveBuildNumber(rootDir);
|
|
88
|
+
const branch = resolveGitBranch(rootDir);
|
|
89
|
+
const tag = resolveGitTag(rootDir);
|
|
90
|
+
const dirty = resolveDirty(rootDir);
|
|
91
|
+
const packageVersion = await resolvePackageVersion(rootDir);
|
|
92
|
+
|
|
93
|
+
return normalizeMeta({
|
|
94
|
+
builtAt,
|
|
95
|
+
buildNumber,
|
|
96
|
+
branch,
|
|
97
|
+
tag,
|
|
98
|
+
dirty,
|
|
99
|
+
environment: process.env.NODE_ENV ?? 'development',
|
|
100
|
+
nodeVersion: process.version,
|
|
101
|
+
sha,
|
|
102
|
+
packageVersion,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { collectRuntimeMeta, normalizeMeta };
|
|
@@ -57,7 +57,8 @@ function clientIsolationPlugin() {
|
|
|
57
57
|
args.path === 'arcway/lib/client' ||
|
|
58
58
|
args.path.startsWith('arcway/lib/client/') ||
|
|
59
59
|
args.path === 'arcway/ui' ||
|
|
60
|
-
args.path.startsWith('arcway/ui/')
|
|
60
|
+
args.path.startsWith('arcway/ui/') ||
|
|
61
|
+
args.path === 'arcway/dynamic'
|
|
61
62
|
) {
|
|
62
63
|
return void 0;
|
|
63
64
|
}
|
package/server/pages/handler.js
CHANGED
|
@@ -16,10 +16,21 @@ import {
|
|
|
16
16
|
import { MIME_TYPES } from './static.js';
|
|
17
17
|
import { createArcwayDevEndpoint } from './arcway-endpoint.js';
|
|
18
18
|
import { buildViteClientManifestJson, buildViteRoute } from './vite-dev.js';
|
|
19
|
+
|
|
20
|
+
function getHtmlHeaders() {
|
|
21
|
+
return {
|
|
22
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
23
|
+
'Cache-Control': 'no-store',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
19
26
|
function createPagesHandler(options) {
|
|
20
27
|
const rootDir = options.rootDir;
|
|
21
|
-
const
|
|
22
|
-
|
|
28
|
+
const resolveOutDir =
|
|
29
|
+
typeof options.resolveOutDir === 'function'
|
|
30
|
+
? () => path.resolve(options.resolveOutDir())
|
|
31
|
+
: () => path.resolve(rootDir, options.outDir ?? '.build/pages');
|
|
32
|
+
let outDir = resolveOutDir();
|
|
33
|
+
let manifestPath = path.join(outDir, 'pages-manifest.json');
|
|
23
34
|
const sessionConfig = options.session;
|
|
24
35
|
const appContext = options.appContext ?? null;
|
|
25
36
|
const mode = options.mode ?? 'production';
|
|
@@ -87,6 +98,11 @@ function createPagesHandler(options) {
|
|
|
87
98
|
refreshFromLazy();
|
|
88
99
|
return;
|
|
89
100
|
}
|
|
101
|
+
const nextOutDir = resolveOutDir();
|
|
102
|
+
if (nextOutDir !== outDir) {
|
|
103
|
+
outDir = nextOutDir;
|
|
104
|
+
manifestPath = path.join(outDir, 'pages-manifest.json');
|
|
105
|
+
}
|
|
90
106
|
if (!fs.existsSync(manifestPath)) return;
|
|
91
107
|
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
92
108
|
routes = compileRoutes(manifest, { rootDir, outDir, viteDev });
|
|
@@ -132,6 +148,7 @@ function createPagesHandler(options) {
|
|
|
132
148
|
}
|
|
133
149
|
// Pick up any invalidations or completed builds that landed since the
|
|
134
150
|
// last request before we resolve the route.
|
|
151
|
+
if (!lazyContext) reload();
|
|
135
152
|
refreshFromLazy();
|
|
136
153
|
const matched = matchPageRoute(routes, pathname);
|
|
137
154
|
if (!matched) {
|
|
@@ -198,7 +215,7 @@ function createPagesHandler(options) {
|
|
|
198
215
|
}
|
|
199
216
|
const status = middlewareResult.status ?? 403;
|
|
200
217
|
const headers = {
|
|
201
|
-
|
|
218
|
+
...getHtmlHeaders(),
|
|
202
219
|
...middlewareResult.headers,
|
|
203
220
|
};
|
|
204
221
|
res.writeHead(status, headers);
|
|
@@ -240,7 +257,7 @@ function createPagesHandler(options) {
|
|
|
240
257
|
projectReact,
|
|
241
258
|
);
|
|
242
259
|
} else if (!res.headersSent) {
|
|
243
|
-
res.writeHead(500,
|
|
260
|
+
res.writeHead(500, getHtmlHeaders());
|
|
244
261
|
res.end('<h1>500 - Internal Server Error</h1>');
|
|
245
262
|
}
|
|
246
263
|
}
|
|
@@ -290,7 +307,7 @@ async function renderDevBuildError(
|
|
|
290
307
|
.replace(/&/g, '&')
|
|
291
308
|
.replace(/</g, '<')
|
|
292
309
|
.replace(/>/g, '>');
|
|
293
|
-
res.writeHead(500,
|
|
310
|
+
res.writeHead(500, getHtmlHeaders());
|
|
294
311
|
res.end(`<h1>500 - Build Error</h1><pre>${escaped}</pre>`);
|
|
295
312
|
}
|
|
296
313
|
}
|
|
@@ -353,6 +370,7 @@ async function runPageMiddleware(
|
|
|
353
370
|
queue: appContext.queue,
|
|
354
371
|
files: appContext.files,
|
|
355
372
|
mail: appContext.mail,
|
|
373
|
+
meta: appContext.meta,
|
|
356
374
|
}
|
|
357
375
|
: { page };
|
|
358
376
|
for (const bundlePath of route.middlewareServerBundles) {
|
package/server/pages/out-dir.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import fsp from 'node:fs/promises';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
2
5
|
|
|
3
|
-
function
|
|
6
|
+
function resolvePagesBaseOutDir(config, rootDir, mode = 'production') {
|
|
4
7
|
const pages = config?.pages ?? {};
|
|
5
8
|
const isDev = mode === 'development';
|
|
6
9
|
const prodOutDir = pages.outDir ?? path.resolve(rootDir, '.build/pages');
|
|
@@ -8,4 +11,102 @@ function resolvePagesOutDir(config, rootDir, mode = 'production') {
|
|
|
8
11
|
return isDev ? devOutDir : prodOutDir;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
function getPagesBuildPaths(baseOutDir) {
|
|
15
|
+
const parentDir = path.dirname(baseOutDir);
|
|
16
|
+
return {
|
|
17
|
+
baseOutDir,
|
|
18
|
+
buildsDir: parentDir,
|
|
19
|
+
metaPath: path.join(parentDir, 'meta.json'),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readActivePagesBuildHash(baseOutDir) {
|
|
24
|
+
const { metaPath } = getPagesBuildPaths(baseOutDir);
|
|
25
|
+
try {
|
|
26
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
27
|
+
return typeof meta?.pagesBuildHash === 'string' && meta.pagesBuildHash.length > 0
|
|
28
|
+
? meta.pagesBuildHash
|
|
29
|
+
: null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveActivePagesOutDir(baseOutDir) {
|
|
36
|
+
const { buildsDir } = getPagesBuildPaths(baseOutDir);
|
|
37
|
+
const buildHash = readActivePagesBuildHash(baseOutDir);
|
|
38
|
+
if (buildHash) {
|
|
39
|
+
const candidate = path.join(buildsDir, buildHash);
|
|
40
|
+
if (fs.existsSync(path.join(candidate, 'pages-manifest.json'))) return candidate;
|
|
41
|
+
}
|
|
42
|
+
return baseOutDir;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolvePagesOutDir(config, rootDir, mode = 'production') {
|
|
46
|
+
const baseOutDir = resolvePagesBaseOutDir(config, rootDir, mode);
|
|
47
|
+
return mode === 'development' ? baseOutDir : resolveActivePagesOutDir(baseOutDir);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createPagesBuildHash(now = new Date()) {
|
|
51
|
+
const iso = now
|
|
52
|
+
.toISOString()
|
|
53
|
+
.replace(/[-:]/g, '')
|
|
54
|
+
.replace(/\.\d{3}Z$/, 'Z');
|
|
55
|
+
return `${iso}-${crypto.randomBytes(4).toString('hex')}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function createPagesVersionedBuildTarget(baseOutDir, buildHash = createPagesBuildHash()) {
|
|
59
|
+
const { buildsDir } = getPagesBuildPaths(baseOutDir);
|
|
60
|
+
await fsp.mkdir(buildsDir, { recursive: true });
|
|
61
|
+
return {
|
|
62
|
+
buildHash,
|
|
63
|
+
outDir: path.join(buildsDir, buildHash),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function pruneOldPagesBuilds(baseOutDir, keepCount = 5) {
|
|
68
|
+
if (!Number.isInteger(keepCount) || keepCount < 1) return;
|
|
69
|
+
const { buildsDir } = getPagesBuildPaths(baseOutDir);
|
|
70
|
+
const activeHash = readActivePagesBuildHash(baseOutDir);
|
|
71
|
+
let entries = [];
|
|
72
|
+
try {
|
|
73
|
+
entries = await fsp.readdir(buildsDir, { withFileTypes: true });
|
|
74
|
+
} catch {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const builds = await Promise.all(
|
|
78
|
+
entries
|
|
79
|
+
.filter((entry) => entry.isDirectory())
|
|
80
|
+
.map(async (entry) => {
|
|
81
|
+
const fullPath = path.join(buildsDir, entry.name);
|
|
82
|
+
const manifestPath = path.join(fullPath, 'pages-manifest.json');
|
|
83
|
+
try {
|
|
84
|
+
const stat = await fsp.stat(manifestPath);
|
|
85
|
+
return { buildHash: entry.name, fullPath, mtimeMs: stat.mtimeMs };
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
const sorted = builds
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs || b.buildHash.localeCompare(a.buildHash));
|
|
94
|
+
const retained = new Set(sorted.slice(0, keepCount).map((entry) => entry.buildHash));
|
|
95
|
+
if (activeHash) retained.add(activeHash);
|
|
96
|
+
await Promise.all(
|
|
97
|
+
sorted
|
|
98
|
+
.filter((entry) => !retained.has(entry.buildHash))
|
|
99
|
+
.map((entry) => fsp.rm(entry.fullPath, { recursive: true, force: true })),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export {
|
|
104
|
+
createPagesBuildHash,
|
|
105
|
+
createPagesVersionedBuildTarget,
|
|
106
|
+
getPagesBuildPaths,
|
|
107
|
+
pruneOldPagesBuilds,
|
|
108
|
+
readActivePagesBuildHash,
|
|
109
|
+
resolveActivePagesOutDir,
|
|
110
|
+
resolvePagesBaseOutDir,
|
|
111
|
+
resolvePagesOutDir,
|
|
112
|
+
};
|
|
@@ -26,7 +26,8 @@ class PagesRouter {
|
|
|
26
26
|
const { config, rootDir, log, mode, fileWatcher } = this;
|
|
27
27
|
const isDev = mode === 'development';
|
|
28
28
|
const viteDev = isDev && config.pages?.vite?.enabled === true;
|
|
29
|
-
const
|
|
29
|
+
const resolveOutDir = () => resolvePagesOutDir(config, rootDir, mode);
|
|
30
|
+
const outDir = resolveOutDir();
|
|
30
31
|
|
|
31
32
|
if (isDev) {
|
|
32
33
|
// Dev mode: `createLazyPagesContext()` builds only the shared bits
|
|
@@ -51,7 +52,7 @@ class PagesRouter {
|
|
|
51
52
|
|
|
52
53
|
this.handler = createPagesHandler({
|
|
53
54
|
rootDir,
|
|
54
|
-
outDir,
|
|
55
|
+
...(isDev ? { outDir } : { resolveOutDir }),
|
|
55
56
|
session: config.session,
|
|
56
57
|
appContext: this.appContext,
|
|
57
58
|
mode,
|