arcway 0.1.21 → 0.1.22

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.
@@ -1,5 +1,5 @@
1
- import { createPagesBuildContext } from './build-context.js';
2
1
  import { createPagesHandler } from './handler.js';
2
+ import { createLazyPagesContext } from './lazy-context.js';
3
3
  import { createPagesWatcher } from './watcher.js';
4
4
 
5
5
  class PagesRouter {
@@ -10,7 +10,7 @@ class PagesRouter {
10
10
  fileWatcher;
11
11
  handler = null;
12
12
  watcher = null;
13
- pagesBuildContext = null;
13
+ lazyContext = null;
14
14
 
15
15
  constructor(config, { rootDir, log, mode, fileWatcher, appContext }) {
16
16
  this.config = config;
@@ -25,24 +25,22 @@ class PagesRouter {
25
25
  const { config, rootDir, log, mode, fileWatcher } = this;
26
26
  const isDev = mode === 'development';
27
27
 
28
- let initialClientMetafile;
29
28
  if (isDev) {
30
- // Dev mode owns a long-lived `createPagesBuildContext()` so the watcher
31
- // can do incremental `ctx.rebuild()` on file change instead of a cold
32
- // `buildPages()`. The initial rebuild bootstraps the outDir and captures
33
- // the client metafile used for HMR diffs.
29
+ // Dev mode: `createLazyPagesContext()` builds only the shared bits
30
+ // (client bundle, CSS, error pages, HMR runtime) at startup. Per-page /
31
+ // layout / middleware server bundles compile on first request via
32
+ // `ensurePageBuilt()` threaded through the handler. The watcher
33
+ // translates file changes into per-entry invalidations on this context.
34
34
  try {
35
- this.pagesBuildContext = await createPagesBuildContext({
35
+ this.lazyContext = await createLazyPagesContext({
36
36
  rootDir,
37
37
  pagesDir: config.pages.dir,
38
38
  fonts: config.pages?.fonts,
39
39
  minify: false,
40
40
  devMode: true,
41
41
  });
42
- const result = await this.pagesBuildContext.rebuild();
43
- initialClientMetafile = result.clientMetafile;
44
42
  } catch (err) {
45
- log.error('Pages build failed', { error: String(err) });
43
+ log.error('Pages lazy context init failed', { error: String(err) });
46
44
  }
47
45
  }
48
46
 
@@ -51,23 +49,20 @@ class PagesRouter {
51
49
  session: config.session,
52
50
  appContext: this.appContext,
53
51
  mode,
52
+ ...(this.lazyContext ? { lazyContext: this.lazyContext } : {}),
54
53
  });
55
54
 
56
55
  if (!this.handler) return;
57
56
 
58
57
  log.info('Pages: SSR enabled');
59
58
 
60
- if (isDev && fileWatcher) {
59
+ if (isDev && fileWatcher && this.lazyContext) {
61
60
  this.watcher = createPagesWatcher({
62
- rootDir,
63
- handler: this.handler,
61
+ lazyContext: this.lazyContext,
64
62
  log,
65
- fonts: config.pages?.fonts,
66
- initialClientMetafile,
67
63
  fileWatcher,
68
- pagesBuildContext: this.pagesBuildContext,
69
64
  });
70
- log.info('Pages: watching for changes (HMR enabled)');
65
+ log.info('Pages: watching for changes (per-entry invalidate)');
71
66
  }
72
67
  }
73
68
 
@@ -81,9 +76,9 @@ class PagesRouter {
81
76
  await this.watcher.close();
82
77
  this.watcher = null;
83
78
  }
84
- if (this.pagesBuildContext) {
85
- await this.pagesBuildContext.dispose();
86
- this.pagesBuildContext = null;
79
+ if (this.lazyContext) {
80
+ await this.lazyContext.dispose();
81
+ this.lazyContext = null;
87
82
  }
88
83
  }
89
84
  }
@@ -1,88 +1,46 @@
1
- import path from 'node:path';
2
- import { buildPages } from './build.js';
3
- import { diffClientMetafiles } from './hmr.js';
1
+ // Dev-only watcher that translates file-system events into per-entry
2
+ // invalidations or structural rediscovery on the lazy pages context.
3
+ //
4
+ // Change events (file edits) flip the affected manifest entries to
5
+ // `stale: true` and bump the version; the next request for that route
6
+ // drives the actual rebuild through `ensurePageBuilt()`. There is no
7
+ // serialization flag here -- lazy builds are per-entry and already
8
+ // coalesced inside the context.
9
+ //
10
+ // Structural events (`add` / `unlink` of page / layout / middleware
11
+ // files) trigger a single `lazyContext.rediscover()` per debounced batch.
12
+ // Rediscover diffs the manifest against discovery output, applies
13
+ // adds/removes, cascades stale flags through layout/middleware chains,
14
+ // and tears down + recreates the client esbuild context when the page
15
+ // or layout set changes.
4
16
 
5
17
  function createPagesWatcher(options) {
6
- const { rootDir, handler, log, fonts, fileWatcher, pagesBuildContext } = options;
7
- let building = false;
8
- let pendingRebuild = false;
9
- let pendingStructuralChange = false;
10
- let prevClientMetafile = options.initialClientMetafile;
11
- const outDir = path.resolve(rootDir, '.build/pages');
12
-
13
- async function runBuild() {
14
- if (pagesBuildContext) return pagesBuildContext.rebuild();
15
- return buildPages({ rootDir, fonts, minify: false, devMode: true });
16
- }
17
-
18
- async function rebuild(isStructuralChange) {
19
- if (building) {
20
- pendingRebuild = true;
21
- if (isStructuralChange) pendingStructuralChange = true;
22
- return;
23
- }
24
- building = true;
25
- try {
26
- const start = Date.now();
27
- const result = await runBuild();
28
- handler.reload();
29
- const elapsed = Date.now() - start;
30
- if (handler.emitEvent && prevClientMetafile && result.clientMetafile && !isStructuralChange) {
31
- const diff = diffClientMetafiles(prevClientMetafile, result.clientMetafile, outDir);
32
- const hmrModules = [...diff.changedNavBundles, ...diff.changedHydrationBundles];
33
- if (diff.needsFullReload) {
34
- handler.emitEvent({ type: 'reload' });
35
- log.info(`Pages rebuilt in ${elapsed}ms (full reload)`);
36
- } else if (hmrModules.length > 0) {
37
- const manifestJson = handler.getClientManifestJson?.();
38
- handler.emitEvent({
39
- type: 'hmr-update',
40
- modules: hmrModules,
41
- timestamp: Date.now(),
42
- ...(manifestJson ? { manifest: manifestJson } : {}),
43
- });
44
- log.info(
45
- `Pages rebuilt in ${elapsed}ms (HMR: ${hmrModules.length} module(s))`,
46
- );
47
- } else if (diff.changedCssBundle) {
48
- handler.emitEvent({
49
- type: 'css-update',
50
- cssBundle: diff.changedCssBundle,
51
- timestamp: Date.now(),
52
- });
53
- log.info(`Pages rebuilt in ${elapsed}ms (CSS update)`);
54
- } else {
55
- log.info(`Pages rebuilt in ${elapsed}ms (no client changes)`);
56
- }
57
- } else {
58
- if (handler.emitEvent) {
59
- handler.emitEvent({ type: 'reload' });
60
- }
61
- log.info(`Pages rebuilt in ${elapsed}ms`);
62
- }
63
- prevClientMetafile = result.clientMetafile;
64
- } catch (err) {
65
- log.error('Pages rebuild failed', { error: String(err) });
66
- } finally {
67
- building = false;
68
- if (pendingRebuild) {
69
- pendingRebuild = false;
70
- const structural = pendingStructuralChange;
71
- pendingStructuralChange = false;
72
- await rebuild(structural);
73
- }
74
- }
18
+ const { lazyContext, log, fileWatcher } = options;
19
+ if (!lazyContext) {
20
+ throw new Error('createPagesWatcher requires a lazyContext');
75
21
  }
76
22
 
77
23
  fileWatcher.subscribe('pages', {
78
24
  extensions: ['.tsx', '.jsx', '.ts', '.js', '.css'],
79
25
  debounceMs: 150,
80
- onChange(events) {
81
- const isStructural = events.some((e) => e.event === 'add' || e.event === 'unlink');
26
+ async onChange(events) {
27
+ const hasStructural = events.some((e) => e.event === 'add' || e.event === 'unlink');
28
+ if (hasStructural) {
29
+ try {
30
+ const result = await lazyContext.rediscover();
31
+ const summary = formatRediscover(result);
32
+ if (summary) log.info(`Pages: rediscovered (${summary})`);
33
+ } catch (err) {
34
+ log.error(`Pages: rediscover failed — ${String(err)}`);
35
+ }
36
+ }
82
37
  for (const e of events) {
83
- log.info(`Page ${e.event === 'unlink' ? 'removed' : e.event === 'add' ? 'added' : 'changed'}: ${e.relativePath}`);
38
+ if (e.event !== 'change') continue;
39
+ const result = lazyContext.invalidate(e.path);
40
+ if (result.touched) {
41
+ log.info(`Pages: invalidated ${e.relativePath} (${formatAffected(result.affected)})`);
42
+ }
84
43
  }
85
- return rebuild(isStructural);
86
44
  },
87
45
  });
88
46
 
@@ -93,4 +51,29 @@ function createPagesWatcher(options) {
93
51
  };
94
52
  }
95
53
 
54
+ function formatAffected(affected) {
55
+ const parts = [];
56
+ if (affected.pages.length) parts.push(`${affected.pages.length} page(s)`);
57
+ if (affected.layouts.length) parts.push(`${affected.layouts.length} layout(s)`);
58
+ if (affected.middlewares.length) parts.push(`${affected.middlewares.length} middleware(s)`);
59
+ return parts.length ? parts.join(', ') : 'no entries';
60
+ }
61
+
62
+ function formatRediscover(result) {
63
+ const parts = [];
64
+ const { added, removed, staleByCascade, clientRebuilt } = result;
65
+ if (added.pages.length || removed.pages.length) {
66
+ parts.push(`pages +${added.pages.length}/-${removed.pages.length}`);
67
+ }
68
+ if (added.layouts.length || removed.layouts.length) {
69
+ parts.push(`layouts +${added.layouts.length}/-${removed.layouts.length}`);
70
+ }
71
+ if (added.middlewares.length || removed.middlewares.length) {
72
+ parts.push(`middlewares +${added.middlewares.length}/-${removed.middlewares.length}`);
73
+ }
74
+ if (staleByCascade.length) parts.push(`${staleByCascade.length} cascaded stale`);
75
+ if (clientRebuilt) parts.push('client rebuilt');
76
+ return parts.join(', ');
77
+ }
78
+
96
79
  export { createPagesWatcher };
@@ -2,6 +2,7 @@ import { Server as SocketIOServer } from 'socket.io';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { registerWsServer, unregisterWsServer } from './registry.js';
4
4
  import { parseCookies, resolveSession, flattenHeaders } from '../session/helpers.js';
5
+ import { getMiddlewareForRoute } from '../router/middleware.js';
5
6
  import { toErrorMessage } from '../helpers.js';
6
7
 
7
8
  class WsRouter {
@@ -199,9 +200,38 @@ class WsRouter {
199
200
  }
200
201
 
201
202
  const reqInfo = this._buildRequestInfo(client, { ...wsMsg, method: 'GET' }, params);
203
+ const ctx = this._apiRouter.buildCtx(reqInfo);
204
+
205
+ // Run api/_middleware.js chain BEFORE invoking route.config.ws(). Without
206
+ // this, ws() side effects (listener registration, resource allocation,
207
+ // initial emits) fire even when auth middleware would have rejected the
208
+ // subscribe. Matches the gating already in place for _handleMethodCall,
209
+ // which routes through executeRoute() (middleware-first).
210
+ const middlewareFns = getMiddlewareForRoute(this._apiRouter.middleware, route.pattern, 'GET');
211
+ for (const mw of middlewareFns) {
212
+ let result;
213
+ try {
214
+ result = await mw(ctx);
215
+ } catch (err) {
216
+ this._log.error(`WS subscribe middleware error for ${msgPath}`, {
217
+ error: toErrorMessage(err),
218
+ });
219
+ this._send(client, {
220
+ path: msgPath,
221
+ error: { code: 'MIDDLEWARE_ERROR', message: 'Middleware error' },
222
+ status: 500,
223
+ id: wsMsg.id,
224
+ });
225
+ return;
226
+ }
227
+ if (result !== undefined && result !== null) {
228
+ this._send(client, { path: msgPath, ...result, id: wsMsg.id });
229
+ return;
230
+ }
231
+ }
232
+
202
233
  let cleanup;
203
234
  try {
204
- const ctx = this._apiRouter.buildCtx(reqInfo);
205
235
  cleanup = await route.config.ws(ctx);
206
236
  } catch (err) {
207
237
  this._log.error(`WS subscribe error for ${msgPath}`, {
@@ -221,7 +251,20 @@ class WsRouter {
221
251
  req: reqInfo,
222
252
  });
223
253
 
224
- const response = await this._apiRouter.executeRoute(route, reqInfo);
254
+ // Middleware already ran above; invoke the handler directly to avoid
255
+ // executing the chain a second time via executeRoute().
256
+ let response;
257
+ try {
258
+ response = await route.config.handler(ctx);
259
+ } catch (err) {
260
+ this._log.error(`WS subscribe handler error for GET ${route.pattern}`, {
261
+ error: toErrorMessage(err),
262
+ });
263
+ response = {
264
+ status: 500,
265
+ error: { code: 'HANDLER_ERROR', message: 'An internal error occurred' },
266
+ };
267
+ }
225
268
  this._send(client, { path: msgPath, ...response, id: wsMsg.id });
226
269
  }
227
270