arcway 0.1.26 → 0.1.28

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 (56) 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 +22 -6
  7. package/server/build.js +73 -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/loader.js +4 -0
  12. package/server/config/modules/cache.js +7 -1
  13. package/server/config/modules/jobs.js +14 -1
  14. package/server/config/modules/mail.js +16 -1
  15. package/server/config/modules/mcp.js +5 -1
  16. package/server/config/modules/pages.js +1 -0
  17. package/server/config/modules/plugins.js +18 -0
  18. package/server/config/modules/queue.js +7 -1
  19. package/server/config/modules/secrets.js +145 -0
  20. package/server/config/modules/server.js +13 -1
  21. package/server/config/modules/session.js +5 -1
  22. package/server/config/modules/websocket.js +8 -0
  23. package/server/context.js +61 -2
  24. package/server/db/index.js +39 -9
  25. package/server/discovery.js +45 -3
  26. package/server/events/drivers/memory.js +22 -4
  27. package/server/events/drivers/redis.js +20 -5
  28. package/server/events/handler.js +21 -7
  29. package/server/events/index.js +2 -2
  30. package/server/index.js +2 -1
  31. package/server/internals.js +8 -0
  32. package/server/jobs/drivers/run-handler.js +3 -0
  33. package/server/jobs/runner.js +13 -4
  34. package/server/jobs/worker-config.js +58 -0
  35. package/server/jobs/worker-entry.js +7 -12
  36. package/server/lib/vault/index.js +20 -0
  37. package/server/lib/vault/secrets.js +149 -0
  38. package/server/meta.js +106 -0
  39. package/server/pages/build-plugins.js +2 -1
  40. package/server/pages/handler.js +23 -5
  41. package/server/pages/out-dir.js +103 -2
  42. package/server/pages/pages-router.js +3 -2
  43. package/server/pages/ssr.js +55 -18
  44. package/server/plugins/capabilities.js +66 -0
  45. package/server/plugins/discovery.js +111 -0
  46. package/server/plugins/graph.js +114 -0
  47. package/server/plugins/manager.js +210 -0
  48. package/server/plugins/manifest.js +94 -0
  49. package/server/router/api-router.js +41 -16
  50. package/server/router/routes.js +14 -2
  51. package/server/server.js +13 -1
  52. package/server/session/index.js +5 -1
  53. package/server/static/index.js +5 -2
  54. package/server/web-server.js +3 -3
  55. package/server/ws/realtime.js +24 -8
  56. package/server/ws/ws-router.js +24 -8
@@ -1,4 +1,3 @@
1
- import path from 'node:path';
2
1
  import { discoverModules } from '../discovery.js';
3
2
  import { buildContext } from '../context.js';
4
3
  import { parseCron, matchesCron } from './cron.js';
@@ -45,7 +44,10 @@ class JobRunner {
45
44
  _stopped = false;
46
45
  _lastEnqueuedMinute = new Map();
47
46
 
48
- constructor(config, { db, queue, cache, files, mail, events, log, workerPool } = {}) {
47
+ constructor(
48
+ config,
49
+ { db, queue, cache, files, mail, events, log, meta, workerPool, plugins } = {},
50
+ ) {
49
51
  this._config = config;
50
52
  this._log = log;
51
53
  this._dispatcher = new JobDispatcher({
@@ -53,7 +55,7 @@ class JobRunner {
53
55
  staleTimeoutMs: config?.staleTimeoutMs,
54
56
  workerPool,
55
57
  });
56
- this._appContext = { db, queue, cache, files, mail, events, log };
58
+ this._appContext = { db, queue, cache, files, mail, events, log, meta, plugins };
57
59
  }
58
60
 
59
61
  get dispatcher() {
@@ -75,13 +77,15 @@ class JobRunner {
75
77
  // Discover and register user jobs. `filePath` is captured so the
76
78
  // dispatcher can dynamic-import the handler inside a worker thread.
77
79
  const discovered = await discoverJobs(jobsDir);
78
- for (const { definition, fileName, filePath } of discovered) {
80
+ const pluginJobs = (await this._appContext.plugins?.discoverJobs?.()) ?? [];
81
+ for (const { definition, fileName, filePath } of [...discovered, ...pluginJobs]) {
79
82
  this._dispatcher.register('app', definition, this._appContext, filePath);
80
83
  this._jobs.push({
81
84
  jobName: definition.name,
82
85
  fileName,
83
86
  schedule: definition.schedule,
84
87
  handler: definition.handler,
88
+ plugin: definition.plugin,
85
89
  cooldownMs: definition.cooldownMs,
86
90
  staleTimeout: definition.staleTimeout,
87
91
  });
@@ -96,6 +100,7 @@ class JobRunner {
96
100
  cache: this._appContext.cache?.withNamespace(SYSTEM_JOB_DOMAIN),
97
101
  files: this._appContext.files?.withNamespace(SYSTEM_JOB_DOMAIN),
98
102
  mail: this._appContext.mail,
103
+ meta: this._appContext.meta,
99
104
  log: this._log.extend({ logger: SYSTEM_JOB_DOMAIN }),
100
105
  };
101
106
  const systemJobs = registerSystemJobs(this._config, this._dispatcher, systemContext);
@@ -109,6 +114,7 @@ class JobRunner {
109
114
  this._continuousJobs.push({
110
115
  qualifiedName: `app/${job.jobName}`,
111
116
  handler: job.handler,
117
+ plugin: job.plugin,
112
118
  cooldownMs: job.cooldownMs,
113
119
  appContext: this._appContext,
114
120
  });
@@ -191,6 +197,9 @@ class JobRunner {
191
197
  try {
192
198
  const start = Date.now();
193
199
  const ctx = buildContext(entry.appContext, { payload: void 0 });
200
+ if (entry.plugin) {
201
+ entry.appContext.plugins?.injectCapabilities(ctx, entry.plugin.requires);
202
+ }
194
203
  await entry.handler(ctx);
195
204
  consecutiveErrors = 0;
196
205
  const elapsed = Date.now() - start;
@@ -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
- const config = workerData?.config;
25
- if (!config) {
26
- throw new Error('Worker started without workerData.config — cannot build job context');
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);
@@ -2,13 +2,33 @@ import { hashPassword, verifyPassword } from './password.js';
2
2
  import { generateUUID, generateNanoId } from './ids.js';
3
3
  import { encrypt, decrypt } from './encrypt.js';
4
4
  import { jwtEncode, jwtDecode } from './jwt.js';
5
+ import {
6
+ decryptWithRotation,
7
+ deriveBytes,
8
+ deriveInfraSecret,
9
+ deriveInfraSecrets,
10
+ deriveVapidKeyPair,
11
+ encodeMasterSecret,
12
+ formatVaultKey,
13
+ normalizeMasterSecret,
14
+ validateVaultKey,
15
+ } from './secrets.js';
5
16
  export {
17
+ decryptWithRotation,
6
18
  decrypt,
19
+ deriveBytes,
20
+ deriveInfraSecret,
21
+ deriveInfraSecrets,
22
+ deriveVapidKeyPair,
23
+ encodeMasterSecret,
7
24
  encrypt,
25
+ formatVaultKey,
8
26
  generateNanoId,
9
27
  generateUUID,
10
28
  hashPassword,
11
29
  jwtDecode,
12
30
  jwtEncode,
31
+ normalizeMasterSecret,
32
+ validateVaultKey,
13
33
  verifyPassword,
14
34
  };
@@ -0,0 +1,149 @@
1
+ import { createECDH, hkdfSync, randomBytes } from 'node:crypto';
2
+
3
+ const MASTER_VERSION = 'v1';
4
+ const MASTER_BYTES = 32;
5
+ const VAULT_KEY_BYTES = 32;
6
+ const HKDF_SALT = 'arcway:infra-secrets:v1';
7
+
8
+ const SECRET_NAMES = Object.freeze([
9
+ 'session',
10
+ 'plugin-vault',
11
+ 'mcp-api',
12
+ 'pty-api',
13
+ 'pty-server-api',
14
+ 'vapid',
15
+ ]);
16
+
17
+ function base64Url(buffer) {
18
+ return Buffer.from(buffer).toString('base64url');
19
+ }
20
+
21
+ function encodeMasterSecret(key = randomBytes(MASTER_BYTES)) {
22
+ if (!Buffer.isBuffer(key) || key.length !== MASTER_BYTES) {
23
+ throw new Error(`Arcway master secret must be ${MASTER_BYTES} bytes`);
24
+ }
25
+ return `${MASTER_VERSION}:${key.toString('base64')}`;
26
+ }
27
+
28
+ function decodeVersionedSecret(value, label = 'Arcway master secret') {
29
+ const trimmed = String(value ?? '').trim();
30
+ const [version, encoded] = trimmed.split(':');
31
+ if (version !== MASTER_VERSION || !encoded || trimmed.split(':').length !== 2) {
32
+ throw new Error(
33
+ `${label} must use ${MASTER_VERSION}:<base64-32-byte-key> or a 32+ character string`,
34
+ );
35
+ }
36
+
37
+ const key = Buffer.from(encoded, 'base64');
38
+ if (key.length !== MASTER_BYTES || key.toString('base64') !== encoded) {
39
+ throw new Error(`${label} must decode to ${MASTER_BYTES} bytes`);
40
+ }
41
+ return key;
42
+ }
43
+
44
+ function normalizeMasterSecret(value, label = 'Arcway master secret') {
45
+ if (Buffer.isBuffer(value)) {
46
+ if (value.length < MASTER_BYTES)
47
+ throw new Error(`${label} must be at least ${MASTER_BYTES} bytes`);
48
+ return value;
49
+ }
50
+
51
+ if (typeof value !== 'string') {
52
+ throw new Error(`${label} must be a string or Buffer`);
53
+ }
54
+
55
+ const trimmed = value.trim();
56
+ if (trimmed.startsWith(`${MASTER_VERSION}:`)) return decodeVersionedSecret(trimmed, label);
57
+ if (trimmed.length < MASTER_BYTES) {
58
+ throw new Error(`${label} must be at least ${MASTER_BYTES} characters`);
59
+ }
60
+ return Buffer.from(trimmed, 'utf8');
61
+ }
62
+
63
+ function deriveBytes(master, namespace, length = MASTER_BYTES) {
64
+ const key = normalizeMasterSecret(master);
65
+ return Buffer.from(hkdfSync('sha256', key, HKDF_SALT, `arcway:${namespace}`, length));
66
+ }
67
+
68
+ function formatVaultKey(bytes) {
69
+ const key = Buffer.from(bytes);
70
+ if (key.length !== VAULT_KEY_BYTES) {
71
+ throw new Error(`Vault key material must be ${VAULT_KEY_BYTES} bytes`);
72
+ }
73
+ return `${MASTER_VERSION}:${key.toString('base64')}`;
74
+ }
75
+
76
+ function validateVaultKey(value, label = 'plugin-vault secret') {
77
+ return formatVaultKey(decodeVersionedSecret(value, label));
78
+ }
79
+
80
+ function deriveVapidKeyPair(master) {
81
+ for (let i = 0; i < 32; i += 1) {
82
+ const privateKey = deriveBytes(master, `vapid:private:${i}`, 32);
83
+ try {
84
+ const ecdh = createECDH('prime256v1');
85
+ ecdh.setPrivateKey(privateKey);
86
+ return {
87
+ publicKey: base64Url(ecdh.getPublicKey(null, 'uncompressed')),
88
+ privateKey: base64Url(privateKey),
89
+ };
90
+ } catch {
91
+ // Try the next deterministic candidate.
92
+ }
93
+ }
94
+ throw new Error('Unable to derive a valid deterministic VAPID keypair');
95
+ }
96
+
97
+ function deriveInfraSecret(master, name) {
98
+ if (name === 'session') return base64Url(deriveBytes(master, name, 48));
99
+ if (name === 'plugin-vault') return formatVaultKey(deriveBytes(master, name, VAULT_KEY_BYTES));
100
+ if (name === 'mcp-api') return base64Url(deriveBytes(master, name, 48));
101
+ if (name === 'pty-api') return base64Url(deriveBytes(master, name, 48));
102
+ if (name === 'pty-server-api') return base64Url(deriveBytes(master, name, 48));
103
+ if (name === 'vapid') return deriveVapidKeyPair(master);
104
+ throw new Error(`Unknown Arcway infra secret namespace: ${name}`);
105
+ }
106
+
107
+ function deriveInfraSecrets(master) {
108
+ const values = {};
109
+ for (const name of SECRET_NAMES) {
110
+ values[name] = deriveInfraSecret(master, name);
111
+ }
112
+ return values;
113
+ }
114
+
115
+ function decryptWithRotation(ciphertext, { current, previous, decrypt, encrypt, onRotate } = {}) {
116
+ if (typeof decrypt !== 'function') throw new Error('decryptWithRotation requires decrypt');
117
+ if (typeof encrypt !== 'function') throw new Error('decryptWithRotation requires encrypt');
118
+ if (!current) throw new Error('decryptWithRotation requires current secret');
119
+
120
+ try {
121
+ return { plaintext: decrypt(ciphertext, current), rotated: false };
122
+ } catch (currentError) {
123
+ const previousValues = Array.isArray(previous)
124
+ ? previous.filter(Boolean)
125
+ : [previous].filter(Boolean);
126
+ for (const previousSecret of previousValues) {
127
+ try {
128
+ const plaintext = decrypt(ciphertext, previousSecret);
129
+ const rotatedCiphertext = encrypt(plaintext, current);
130
+ onRotate?.(rotatedCiphertext);
131
+ return { plaintext, rotated: true, rotatedCiphertext };
132
+ } catch {}
133
+ }
134
+ throw currentError;
135
+ }
136
+ }
137
+
138
+ export {
139
+ SECRET_NAMES,
140
+ decryptWithRotation,
141
+ deriveBytes,
142
+ deriveInfraSecret,
143
+ deriveInfraSecrets,
144
+ deriveVapidKeyPair,
145
+ encodeMasterSecret,
146
+ formatVaultKey,
147
+ normalizeMasterSecret,
148
+ validateVaultKey,
149
+ };
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
  }
@@ -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 outDir = path.resolve(rootDir, options.outDir ?? '.build/pages');
22
- const manifestPath = path.join(outDir, 'pages-manifest.json');
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
- 'Content-Type': 'text/html; charset=utf-8',
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, { 'Content-Type': 'text/html' });
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, '&amp;')
291
308
  .replace(/</g, '&lt;')
292
309
  .replace(/>/g, '&gt;');
293
- res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
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) {
@@ -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 resolvePagesOutDir(config, rootDir, mode = 'production') {
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
- export { resolvePagesOutDir };
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 outDir = resolvePagesOutDir(config, rootDir, mode);
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,