arcway 0.1.25 → 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 (55) hide show
  1. package/client/fetcher.js +4 -1
  2. package/client/hooks/swr-compat.js +27 -0
  3. package/client/hooks/use-api-paginated.js +74 -0
  4. package/client/hooks/use-api.js +1 -4
  5. package/client/hooks/use-graphql.js +1 -2
  6. package/client/hooks/use-mutation.js +1 -1
  7. package/client/hooks/web/use-local-storage.js +54 -16
  8. package/client/index.js +16 -55
  9. package/client/router.js +51 -29
  10. package/client/ui/index.js +62 -380
  11. package/package.json +4 -3
  12. package/server/bin/commands/build.js +3 -0
  13. package/server/boot/index.js +12 -6
  14. package/server/build.js +63 -5
  15. package/server/cache/index.js +12 -2
  16. package/server/cache/ttl.js +24 -0
  17. package/server/config/duration.js +49 -0
  18. package/server/config/modules/cache.js +7 -1
  19. package/server/config/modules/jobs.js +14 -1
  20. package/server/config/modules/mail.js +16 -1
  21. package/server/config/modules/pages.js +3 -2
  22. package/server/config/modules/queue.js +7 -1
  23. package/server/config/modules/server.js +13 -1
  24. package/server/config/modules/websocket.js +8 -0
  25. package/server/context.js +61 -2
  26. package/server/db/index.js +6 -1
  27. package/server/events/drivers/memory.js +22 -4
  28. package/server/events/drivers/redis.js +20 -5
  29. package/server/events/handler.js +21 -7
  30. package/server/events/index.js +2 -2
  31. package/server/index.js +7 -33
  32. package/server/jobs/runner.js +3 -2
  33. package/server/jobs/worker-config.js +58 -0
  34. package/server/jobs/worker-entry.js +7 -12
  35. package/server/meta.js +106 -0
  36. package/server/pages/build-client.js +1 -1
  37. package/server/pages/build-plugins.js +2 -1
  38. package/server/pages/chunk-graph.js +1 -0
  39. package/server/pages/fonts.js +14 -1
  40. package/server/pages/handler.js +25 -7
  41. package/server/pages/lazy-context.js +2 -2
  42. package/server/pages/out-dir.js +104 -3
  43. package/server/pages/pages-router.js +3 -2
  44. package/server/pages/ssr.js +55 -18
  45. package/server/pages/vite-dev.js +38 -3
  46. package/server/router/api-router.js +71 -2
  47. package/server/router/ratelimit.js +50 -0
  48. package/server/router/routes.js +10 -0
  49. package/server/server.js +13 -1
  50. package/server/session/index.js +5 -1
  51. package/server/static/index.js +5 -2
  52. package/server/web-server.js +3 -3
  53. package/server/ws/realtime.js +24 -8
  54. package/server/ws/ws-router.js +24 -8
  55. package/client/hooks/use-form.js +0 -86
@@ -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') return item.handler;
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(items[i], filePath, items.length > 1 ? i : undefined);
58
- this._events.subscribe(event, (_eventName, payload) => {
59
- const ctx = buildContext(this._appContext, { event: { name: _eventName, payload } });
60
- handler(ctx);
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)` : ''}`);
@@ -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
@@ -1,8 +1,7 @@
1
- import { type } from 'arktype';
2
- import { buildContext } from './context.js';
3
- import * as vault from './lib/vault/index.js';
4
- import boot from './boot.js';
5
- import {
1
+ export { type } from 'arktype';
2
+ export { buildContext } from './context.js';
3
+ export { default as boot } from './boot.js';
4
+ export {
6
5
  Arcway,
7
6
  Solo,
8
7
  createTestContext,
@@ -13,41 +12,16 @@ import {
13
12
  createMailStub,
14
13
  createLoggerStub,
15
14
  } from './testing/index.js';
16
- import {
15
+ export {
17
16
  createRateLimitMiddleware,
18
17
  MemoryRateLimitStore,
19
18
  RedisRateLimitStore,
20
19
  } from './rate-limit/index.js';
21
- import { wsSendToSocket, wsBroadcastToPath } from './ws/registry.js';
22
- import {
20
+ export { wsSendToSocket, wsBroadcastToPath } from './ws/registry.js';
21
+ export {
23
22
  SYSTEM_JOB_DOMAIN,
24
23
  addSystemJobEntry,
25
24
  registerSystemJobs,
26
25
  validateNoSystemJobCollision,
27
26
  getApplicableSystemJobs,
28
27
  } from './system-jobs/index.js';
29
- export {
30
- MemoryRateLimitStore,
31
- RedisRateLimitStore,
32
- Arcway,
33
- SYSTEM_JOB_DOMAIN,
34
- Solo,
35
- addSystemJobEntry,
36
- boot,
37
- buildContext,
38
- createCacheStub,
39
- createEventStub,
40
- createFilesStub,
41
- createLoggerStub,
42
- createMailStub,
43
- createQueueStub,
44
- createRateLimitMiddleware,
45
- createTestContext,
46
- getApplicableSystemJobs,
47
- registerSystemJobs,
48
- type,
49
- validateNoSystemJobCollision,
50
- vault,
51
- wsBroadcastToPath,
52
- wsSendToSocket,
53
- };
@@ -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
- 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);
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 };
@@ -18,7 +18,7 @@ import { computeEntryChunks } from './chunk-graph.js';
18
18
  // Bump when the shape produced by `extractMetadata` / the serialized cache
19
19
  // payload changes in a way that would make an old cache entry decode into a
20
20
  // broken manifest. Prevents a stale v1 cache from ever satisfying a v2 build.
21
- const METADATA_VERSION = 3;
21
+ const METADATA_VERSION = 4;
22
22
 
23
23
  // Build a deterministic plan of the hydration entries that will be written
24
24
  // into the staging dir. Computed up-front so we can derive a cache key
@@ -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
  }
@@ -39,6 +39,7 @@ function computeEntryChunks(metafile, clientDir) {
39
39
  const meta = outputs[cur];
40
40
  if (!meta) continue;
41
41
  for (const imp of meta.imports ?? []) {
42
+ if (imp.kind === 'dynamic-import') continue;
42
43
  const target = imp.path;
43
44
  if (!target || visited.has(target)) continue;
44
45
  visited.add(target);
@@ -36,6 +36,19 @@ const NAMED_WEIGHTS = {
36
36
  black: 900,
37
37
  heavy: 900,
38
38
  };
39
+ const NAMED_WEIGHT_KEYS = Object.keys(NAMED_WEIGHTS).sort((a, b) => b.length - a.length);
40
+
41
+ function resolveNamedWeight(baseName) {
42
+ if (!baseName) return void 0;
43
+ const normalized = baseName.toLowerCase().replace(/[\s._-]+/g, '');
44
+ for (const key of NAMED_WEIGHT_KEYS) {
45
+ if (normalized === key || normalized.endsWith(key)) {
46
+ return NAMED_WEIGHTS[key];
47
+ }
48
+ }
49
+ return void 0;
50
+ }
51
+
39
52
  async function scanFontDirectory(srcDir, publicDir) {
40
53
  const dirPath = path.join(publicDir, srcDir);
41
54
  let entries;
@@ -54,7 +67,7 @@ async function scanFontDirectory(srcDir, publicDir) {
54
67
  if (!isNaN(numericWeight) && numericWeight >= 1 && numericWeight <= 999) {
55
68
  weight = numericWeight;
56
69
  } else {
57
- weight = NAMED_WEIGHTS[baseName];
70
+ weight = resolveNamedWeight(baseName);
58
71
  }
59
72
  if (weight === void 0) continue;
60
73
  files.push({
@@ -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);
@@ -225,7 +242,7 @@ function createPagesHandler(options) {
225
242
  if (manifest.errorBundle && !res.headersSent) {
226
243
  const errorMessage = devMode
227
244
  ? err instanceof Error
228
- ? err.message
245
+ ? err.stack || err.message
229
246
  : String(err)
230
247
  : 'An unexpected error occurred';
231
248
  await renderErrorPage(
@@ -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
  }
@@ -270,7 +287,7 @@ async function renderDevBuildError(
270
287
  cacheVersion,
271
288
  projectReact,
272
289
  ) {
273
- const errorMessage = err instanceof Error ? err.message : String(err);
290
+ const errorMessage = err instanceof Error ? err.stack || err.message : String(err);
274
291
  if (manifest.errorBundle && !res.headersSent) {
275
292
  await renderErrorPage(
276
293
  manifest.errorBundle,
@@ -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) {
@@ -161,7 +161,7 @@ async function createLazyPagesContext(options) {
161
161
  minify,
162
162
  limit,
163
163
  }),
164
- syncViteHydrationEntries({ manifest, outDir }),
164
+ syncViteHydrationEntries({ manifest, outDir, fontFaceCss }),
165
165
  ]);
166
166
  if (errorBundles.error) manifest.errorBundle = errorBundles.error;
167
167
  if (errorBundles.notFound) manifest.notFoundBundle = errorBundles.notFound;
@@ -708,7 +708,7 @@ async function createLazyPagesContext(options) {
708
708
  if (viteDev) {
709
709
  const nextStylesPath = await discoverStyles(pagesDir);
710
710
  manifest.stylesPath = nextStylesPath ? path.resolve(nextStylesPath) : null;
711
- await syncViteHydrationEntries({ manifest, outDir });
711
+ await syncViteHydrationEntries({ manifest, outDir, fontFaceCss });
712
712
  }
713
713
 
714
714
  // ── Version bump + event emission ────────────────────────────────
@@ -1,11 +1,112 @@
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');
7
- const devOutDir = pages.devOutDir ?? path.resolve(rootDir, '.build-dev/pages');
10
+ const devOutDir = pages.devOutDir ?? path.resolve(rootDir, '.build/dev/pages');
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,