@ecopages/core 0.2.0-alpha.25 → 0.2.0-alpha.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 (111) hide show
  1. package/README.md +63 -7
  2. package/package.json +4 -47
  3. package/src/adapters/bun/create-app.ts +54 -2
  4. package/src/adapters/bun/hmr-manager.test.ts +0 -2
  5. package/src/adapters/bun/hmr-manager.ts +1 -24
  6. package/src/adapters/bun/server-adapter.ts +30 -4
  7. package/src/adapters/node/node-hmr-manager.test.ts +0 -2
  8. package/src/adapters/node/node-hmr-manager.ts +2 -25
  9. package/src/adapters/shared/explicit-static-render-preparation.ts +58 -0
  10. package/src/adapters/shared/explicit-static-route-matcher.test.ts +6 -6
  11. package/src/adapters/shared/explicit-static-route-matcher.ts +22 -31
  12. package/src/adapters/shared/file-route-middleware-pipeline.test.ts +5 -10
  13. package/src/adapters/shared/file-route-middleware-pipeline.ts +8 -17
  14. package/src/adapters/shared/fs-server-response-factory.test.ts +32 -43
  15. package/src/adapters/shared/fs-server-response-factory.ts +15 -37
  16. package/src/adapters/shared/fs-server-response-matcher.test.ts +65 -39
  17. package/src/adapters/shared/fs-server-response-matcher.ts +94 -43
  18. package/src/adapters/shared/hmr-manager.contract.test.ts +0 -4
  19. package/src/adapters/shared/render-context.ts +3 -3
  20. package/src/adapters/shared/server-adapter.test.ts +53 -0
  21. package/src/adapters/shared/server-adapter.ts +228 -159
  22. package/src/adapters/shared/server-route-handler.test.ts +6 -5
  23. package/src/adapters/shared/server-route-handler.ts +4 -4
  24. package/src/adapters/shared/server-static-builder.test.ts +4 -4
  25. package/src/adapters/shared/server-static-builder.ts +4 -4
  26. package/src/config/README.md +1 -1
  27. package/src/config/config-builder.test.ts +0 -1
  28. package/src/config/config-builder.ts +2 -7
  29. package/src/dev/host-runtime.ts +34 -0
  30. package/src/eco/eco.browser.test.ts +2 -2
  31. package/src/eco/eco.browser.ts +2 -2
  32. package/src/eco/eco.test.ts +6 -6
  33. package/src/eco/eco.ts +12 -12
  34. package/src/eco/eco.types.ts +3 -3
  35. package/src/errors/index.ts +1 -0
  36. package/src/hmr/client/hmr-runtime.ts +4 -2
  37. package/src/hmr/strategies/js-hmr-strategy.test.ts +0 -1
  38. package/src/hmr/strategies/js-hmr-strategy.ts +0 -6
  39. package/src/integrations/ghtml/ghtml-renderer.test.ts +7 -7
  40. package/src/integrations/ghtml/ghtml-renderer.ts +1 -11
  41. package/src/plugins/eco-component-meta-plugin.ts +0 -1
  42. package/src/plugins/integration-plugin.test.ts +9 -14
  43. package/src/plugins/integration-plugin.ts +34 -22
  44. package/src/plugins/processor.ts +17 -0
  45. package/src/route-renderer/GRAPH.md +81 -289
  46. package/src/route-renderer/README.md +67 -105
  47. package/src/route-renderer/orchestration/component-render-context.ts +45 -38
  48. package/src/route-renderer/orchestration/declared-ownership-graph.ts +62 -0
  49. package/src/route-renderer/orchestration/foreign-subtree-execution.service.ts +383 -0
  50. package/src/route-renderer/orchestration/integration-renderer.test.ts +118 -121
  51. package/src/route-renderer/orchestration/integration-renderer.ts +362 -403
  52. package/src/route-renderer/orchestration/ownership-planning.service.ts +97 -0
  53. package/src/route-renderer/orchestration/ownership-validation.service.ts +76 -0
  54. package/src/route-renderer/orchestration/processed-asset-dedupe.ts +1 -1
  55. package/src/route-renderer/orchestration/{queued-boundary-runtime.service.test.ts → queued-foreign-subtree-resolution.service.test.ts} +76 -71
  56. package/src/route-renderer/orchestration/{queued-boundary-runtime.service.ts → queued-foreign-subtree-resolution.service.ts} +68 -63
  57. package/src/route-renderer/orchestration/render-output.utils.ts +21 -13
  58. package/src/route-renderer/orchestration/{render-preparation.service.test.ts → route-render-orchestrator.prepare-render-options.test.ts} +160 -85
  59. package/src/route-renderer/orchestration/route-render-orchestrator.test.ts +265 -0
  60. package/src/route-renderer/orchestration/{render-preparation.service.ts → route-render-orchestrator.ts} +244 -160
  61. package/src/route-renderer/page-loading/component-dependency-collection.ts +9 -3
  62. package/src/route-renderer/page-loading/declared-asset-collection.ts +2 -5
  63. package/src/route-renderer/page-loading/dependency-resolver.test.ts +107 -11
  64. package/src/route-renderer/page-loading/dependency-resolver.ts +6 -12
  65. package/src/route-renderer/page-loading/ecopages-virtual-imports.ts +1 -1
  66. package/src/route-renderer/page-loading/lazy-entry-collection.ts +1 -1
  67. package/src/route-renderer/page-loading/lazy-trigger-planning.ts +1 -1
  68. package/src/route-renderer/page-loading/module-declaration-aggregation.ts +1 -1
  69. package/src/route-renderer/page-loading/module-declaration-scripts.ts +1 -1
  70. package/src/route-renderer/page-loading/page-dependency-bundling.ts +105 -66
  71. package/src/route-renderer/route-renderer.ts +28 -31
  72. package/src/router/README.md +16 -19
  73. package/src/router/server/route-registry.test.ts +176 -0
  74. package/src/router/server/route-registry.ts +382 -0
  75. package/src/services/README.md +1 -2
  76. package/src/services/assets/asset-processing-service/asset-dependency-keys.ts +1 -1
  77. package/src/services/assets/asset-processing-service/asset-processing.service.test.ts +1 -4
  78. package/src/services/assets/asset-processing-service/asset-processing.service.ts +1 -2
  79. package/src/services/assets/asset-processing-service/assets.types.ts +3 -0
  80. package/src/services/assets/asset-processing-service/grouped-content-bundles.ts +1 -1
  81. package/src/services/assets/asset-processing-service/index.ts +1 -0
  82. package/src/{route-renderer/orchestration/page-packaging.service.test.ts → services/assets/asset-processing-service/page-package.test.ts} +38 -14
  83. package/src/services/assets/asset-processing-service/page-package.ts +93 -0
  84. package/src/services/assets/asset-processing-service/processors/base/base-script-processor.ts +4 -5
  85. package/src/services/assets/asset-processing-service/processors/script/content-script.processor.test.ts +13 -10
  86. package/src/services/assets/asset-processing-service/processors/script/content-script.processor.ts +3 -0
  87. package/src/services/assets/asset-processing-service/processors/script/file-script.processor.ts +6 -0
  88. package/src/services/assets/asset-processing-service/processors/script/node-module-script.processor.ts +2 -0
  89. package/src/services/assets/asset-processing-service/processors/stylesheet/content-stylesheet.processor.ts +1 -0
  90. package/src/services/assets/asset-processing-service/processors/stylesheet/file-stylesheet.processor.ts +2 -0
  91. package/src/services/assets/asset-processing-service/ungrouped-dependency-processing.ts +1 -1
  92. package/src/services/html/html-transformer.service.test.ts +1 -4
  93. package/src/services/module-loading/app-server-module-transpiler.service.ts +1 -3
  94. package/src/services/module-loading/node-bootstrap-plugin.ts +17 -3
  95. package/src/services/module-loading/page-module-import.service.ts +0 -1
  96. package/src/services/module-loading/source-module-support.ts +1 -1
  97. package/src/static-site-generator/static-site-generator.test.ts +124 -32
  98. package/src/static-site-generator/static-site-generator.ts +168 -185
  99. package/src/types/internal-types.ts +13 -12
  100. package/src/types/public-types.ts +55 -39
  101. package/src/watchers/project-watcher.test-helpers.ts +4 -3
  102. package/src/route-renderer/orchestration/boundary-planning.service.ts +0 -146
  103. package/src/route-renderer/orchestration/page-packaging.service.ts +0 -85
  104. package/src/route-renderer/orchestration/render-execution.service.test.ts +0 -196
  105. package/src/route-renderer/orchestration/render-execution.service.ts +0 -182
  106. package/src/route-renderer/orchestration/route-shell-composer.service.ts +0 -162
  107. package/src/router/server/fs-router-scanner.test.ts +0 -83
  108. package/src/router/server/fs-router-scanner.ts +0 -224
  109. package/src/router/server/fs-router.test.ts +0 -214
  110. package/src/router/server/fs-router.ts +0 -122
  111. package/src/services/runtime-state/runtime-specifier-registry.service.ts +0 -96
@@ -1,7 +1,8 @@
1
1
  import { appLogger } from '../../global/app-logger.ts';
2
2
  import type { EcoPagesAppConfig } from '../../types/internal-types.ts';
3
3
  import type { StaticRoute } from '../../types/public-types.ts';
4
- import type { RouteRendererFactory } from '../../route-renderer/route-renderer.ts';
4
+ import type { ExplicitViewRendererResolver } from '../../route-renderer/route-renderer.ts';
5
+ import { prepareExplicitStaticRender } from './explicit-static-render-preparation.ts';
5
6
 
6
7
  export const EXPLICIT_STATIC_ROUTE_MATCHER_ERRORS = {
7
8
  missingIntegration: (routePath: string) =>
@@ -9,15 +10,9 @@ export const EXPLICIT_STATIC_ROUTE_MATCHER_ERRORS = {
9
10
  noRendererForIntegration: (integrationName: string) => `No renderer found for integration: ${integrationName}`,
10
11
  } as const;
11
12
 
12
- function getViewIntegrationName(view: {
13
- config?: { integration?: string; __eco?: { integration?: string } };
14
- }): string | undefined {
15
- return view.config?.integration ?? view.config?.__eco?.integration;
16
- }
17
-
18
13
  export interface ExplicitStaticRouteMatcherOptions {
19
14
  appConfig: EcoPagesAppConfig;
20
- routeRendererFactory: RouteRendererFactory;
15
+ routeRendererFactory: ExplicitViewRendererResolver;
21
16
  staticRoutes: StaticRoute[];
22
17
  }
23
18
 
@@ -26,9 +21,12 @@ export interface ExplicitRouteMatch {
26
21
  params: Record<string, string>;
27
22
  }
28
23
 
24
+ /**
25
+ * Matches and renders explicit static routes declared through `app.static()`.
26
+ */
29
27
  export class ExplicitStaticRouteMatcher {
30
28
  private readonly appConfig: EcoPagesAppConfig;
31
- private readonly routeRendererFactory: RouteRendererFactory;
29
+ private readonly routeRendererFactory: ExplicitViewRendererResolver;
32
30
  private readonly staticRoutes: StaticRoute[];
33
31
 
34
32
  constructor({ appConfig, routeRendererFactory, staticRoutes }: ExplicitStaticRouteMatcherOptions) {
@@ -107,28 +105,21 @@ export class ExplicitStaticRouteMatcher {
107
105
  try {
108
106
  const mod = await route.loader();
109
107
  const view = mod.default;
110
-
111
- const integrationName = getViewIntegrationName(view);
112
- if (!integrationName) {
113
- throw new Error(EXPLICIT_STATIC_ROUTE_MATCHER_ERRORS.missingIntegration(route.path));
114
- }
115
-
116
- const renderer = this.routeRendererFactory.getRendererByIntegration(integrationName);
117
- if (!renderer) {
118
- throw new Error(EXPLICIT_STATIC_ROUTE_MATCHER_ERRORS.noRendererForIntegration(integrationName));
119
- }
120
-
121
- const props = view.staticProps
122
- ? (
123
- await view.staticProps({
124
- pathname: { params },
125
- appConfig: this.appConfig,
126
- runtimeOrigin: this.appConfig.baseUrl,
127
- })
128
- ).props
129
- : {};
130
-
131
- return renderer.renderToResponse(view, props, {});
108
+ const {
109
+ renderer,
110
+ props,
111
+ view: renderableView,
112
+ } = await prepareExplicitStaticRender({
113
+ routePath: route.path,
114
+ view,
115
+ params,
116
+ appConfig: this.appConfig,
117
+ runtimeOrigin: this.appConfig.baseUrl,
118
+ routeRendererFactory: this.routeRendererFactory,
119
+ errors: EXPLICIT_STATIC_ROUTE_MATCHER_ERRORS,
120
+ });
121
+
122
+ return renderer.renderToResponse(renderableView, props, {});
132
123
  } catch (error) {
133
124
  appLogger.error(
134
125
  `Error rendering explicit static route ${route.path}:`,
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import type { Middleware } from '../../types/public-types.ts';
2
+ import type { FileRouteMiddleware } from '../../types/public-types.ts';
3
3
  import { LocalsAccessError } from '../../errors/locals-access-error.ts';
4
4
  import {
5
5
  FILE_ROUTE_MIDDLEWARE_PIPELINE_ERRORS,
@@ -30,21 +30,16 @@ describe('FileRouteMiddlewarePipeline', () => {
30
30
  );
31
31
  });
32
32
 
33
- it('should create middleware context with render methods disabled', async () => {
33
+ it('should create middleware context without handler render methods', async () => {
34
34
  const service = new FileRouteMiddlewarePipeline(null);
35
- const DummyView = (() => '<div>dummy</div>') as never;
36
35
  const context = service.createContext({
37
36
  request: new Request('http://localhost:3000/hello'),
38
37
  params: { slug: 'hello' },
39
38
  locals: { user: 'andee' },
40
39
  });
41
40
 
42
- await expect(context.render(DummyView, {})).rejects.toThrow(
43
- FILE_ROUTE_MIDDLEWARE_PIPELINE_ERRORS.CTX_RENDER_UNAVAILABLE,
44
- );
45
- await expect(context.renderPartial(DummyView, {})).rejects.toThrow(
46
- FILE_ROUTE_MIDDLEWARE_PIPELINE_ERRORS.CTX_RENDER_PARTIAL_UNAVAILABLE,
47
- );
41
+ expect('render' in context).toBe(false);
42
+ expect('renderPartial' in context).toBe(false);
48
43
 
49
44
  expect(context.require('user', () => new Response('missing', { status: 500 }))).toBe('andee');
50
45
  expect(await context.html('<p>ok</p>').text()).toContain('<p>ok</p>');
@@ -60,7 +55,7 @@ describe('FileRouteMiddlewarePipeline', () => {
60
55
  });
61
56
  const events: string[] = [];
62
57
 
63
- const middleware: Middleware[] = [
58
+ const middleware: FileRouteMiddleware[] = [
64
59
  async (ctx, next) => {
65
60
  events.push('first:before');
66
61
  ctx.locals.first = true;
@@ -1,12 +1,10 @@
1
1
  import { createRequire } from '../../utils/locals-utils.ts';
2
- import type { Middleware, ApiHandlerContext, RequestLocals } from '../../types/public-types.ts';
2
+ import type { FileRouteMiddleware, FileRouteMiddlewareContext, RequestLocals } from '../../types/public-types.ts';
3
3
  import type { PageCacheService } from '../../services/cache/page-cache-service.ts';
4
4
  import { ApiResponseBuilder } from './api-response.ts';
5
5
  import { LocalsAccessError } from '../../errors/locals-access-error.ts';
6
6
 
7
7
  export const FILE_ROUTE_MIDDLEWARE_PIPELINE_ERRORS = {
8
- CTX_RENDER_UNAVAILABLE: '[ecopages] ctx.render is not available in file-route middleware',
9
- CTX_RENDER_PARTIAL_UNAVAILABLE: '[ecopages] ctx.renderPartial is not available in file-route middleware',
10
8
  middlewareRequiresDynamic: (filePath: string) =>
11
9
  `[ecopages] Page middleware requires cache: 'dynamic'. Page: ${filePath}`,
12
10
  } as const;
@@ -36,7 +34,7 @@ export class FileRouteMiddlewarePipeline {
36
34
  * @throws LocalsAccessError When middleware is configured for a non-dynamic page.
37
35
  */
38
36
  assertValidConfiguration(input: {
39
- middleware: Middleware[];
37
+ middleware: FileRouteMiddleware[];
40
38
  pageCacheStrategy: 'static' | 'dynamic' | { revalidate: number; tags?: string[] };
41
39
  filePath: string;
42
40
  }): void {
@@ -50,9 +48,8 @@ export class FileRouteMiddlewarePipeline {
50
48
  /**
51
49
  * Creates the request-scoped middleware context used by page middleware.
52
50
  *
53
- * The context intentionally disables `render()` and `renderPartial()` inside
54
- * file-route middleware because rendering is owned by the page route pipeline,
55
- * not by middleware stages.
51
+ * The context intentionally omits `render()` and `renderPartial()` because
52
+ * rendering is owned by the page route pipeline, not by middleware stages.
56
53
  *
57
54
  * @param input Request details and the mutable locals store.
58
55
  * @returns Middleware execution context.
@@ -61,8 +58,8 @@ export class FileRouteMiddlewarePipeline {
61
58
  request: Request;
62
59
  params: Record<string, string>;
63
60
  locals: RequestLocals;
64
- }): ApiHandlerContext {
65
- const context: ApiHandlerContext = {
61
+ }): FileRouteMiddlewareContext {
62
+ const context: FileRouteMiddlewareContext = {
66
63
  request: input.request,
67
64
  params: input.params,
68
65
  response: new ApiResponseBuilder(),
@@ -72,12 +69,6 @@ export class FileRouteMiddlewarePipeline {
72
69
  },
73
70
  locals: input.locals,
74
71
  require: createRequire(() => context.locals as unknown as Record<string, unknown>),
75
- render: async () => {
76
- throw new Error(FILE_ROUTE_MIDDLEWARE_PIPELINE_ERRORS.CTX_RENDER_UNAVAILABLE);
77
- },
78
- renderPartial: async () => {
79
- throw new Error(FILE_ROUTE_MIDDLEWARE_PIPELINE_ERRORS.CTX_RENDER_PARTIAL_UNAVAILABLE);
80
- },
81
72
  json: (data, options) => {
82
73
  const builder = new ApiResponseBuilder();
83
74
  if (options?.status) builder.status(options.status);
@@ -105,8 +96,8 @@ export class FileRouteMiddlewarePipeline {
105
96
  * @returns Response from middleware or final render stage.
106
97
  */
107
98
  async run(input: {
108
- middleware: Middleware[];
109
- context: ApiHandlerContext;
99
+ middleware: FileRouteMiddleware[];
100
+ context: FileRouteMiddlewareContext;
110
101
  renderResponse: () => Promise<Response>;
111
102
  }): Promise<Response> {
112
103
  if (input.middleware.length === 0) {
@@ -1,4 +1,5 @@
1
1
  import { beforeAll, describe, expect, it, vi } from 'vitest';
2
+ import { fileSystem } from '@ecopages/file-system';
2
3
  import {
3
4
  FIXTURE_APP_PROJECT_DIR,
4
5
  FIXTURE_EXISTING_CSS_FILE_IN_DIST,
@@ -7,7 +8,6 @@ import {
7
8
  import { appLogger } from '../../global/app-logger.ts';
8
9
  import { ConfigBuilder } from '../../config/config-builder.ts';
9
10
  import { STATUS_MESSAGE } from '../../config/constants.ts';
10
- import { RouteRendererFactory } from '../../route-renderer/route-renderer.ts';
11
11
  import { FileSystemServerResponseFactory } from './fs-server-response-factory.ts';
12
12
 
13
13
  let appConfig: Awaited<ReturnType<ConfigBuilder['build']>>;
@@ -23,11 +23,6 @@ describe('FileSystemServerResponseFactory', () => {
23
23
  }
24
24
 
25
25
  responseFactory = new FileSystemServerResponseFactory({
26
- appConfig,
27
- routeRendererFactory: new RouteRendererFactory({
28
- appConfig,
29
- runtimeOrigin: appConfig.baseUrl,
30
- }),
31
26
  options: {
32
27
  watchMode: false,
33
28
  },
@@ -54,11 +49,6 @@ describe('FileSystemServerResponseFactory', () => {
54
49
  describe('shouldEnableGzip', () => {
55
50
  it('should return false in watch mode', () => {
56
51
  const responseFactoryWatch = new FileSystemServerResponseFactory({
57
- appConfig,
58
- routeRendererFactory: new RouteRendererFactory({
59
- appConfig,
60
- runtimeOrigin: appConfig.baseUrl,
61
- }),
62
52
  options: {
63
53
  watchMode: true,
64
54
  },
@@ -102,34 +92,9 @@ describe('FileSystemServerResponseFactory', () => {
102
92
  });
103
93
  });
104
94
 
105
- describe('createCustomNotFoundResponse', () => {
106
- it('should create a response with status 404 if error404 template file does not exist', async () => {
107
- const customAppConfig = {
108
- ...appConfig,
109
- absolutePaths: {
110
- ...appConfig.absolutePaths,
111
- error404TemplatePath: 'non-existent-file',
112
- },
113
- };
114
-
115
- const responseFactoryNo404Template = new FileSystemServerResponseFactory({
116
- appConfig: customAppConfig,
117
- routeRendererFactory: new RouteRendererFactory({
118
- appConfig: customAppConfig,
119
- runtimeOrigin: customAppConfig.baseUrl,
120
- }),
121
- options: {
122
- watchMode: false,
123
- },
124
- });
125
-
126
- const response = await responseFactoryNo404Template.createCustomNotFoundResponse();
127
- expect(response.status).toBe(404);
128
- expect(await response.text()).toBe(STATUS_MESSAGE[404]);
129
- });
130
-
131
- it('should create a response with the route renderer body if error404 template file exists', async () => {
132
- const response = await responseFactory.createCustomNotFoundResponse();
95
+ describe('createHtmlNotFoundResponse', () => {
96
+ it('should create an html 404 response from a rendered body', async () => {
97
+ const response = await responseFactory.createHtmlNotFoundResponse('<h1>404 - Page Not Found</h1>');
133
98
  const body = await response.text();
134
99
  expect(body).toContain('<h1>404 - Page Not Found</h1>');
135
100
  expect(response.headers.get('Content-Type')).toBe('text/html');
@@ -139,18 +104,24 @@ describe('FileSystemServerResponseFactory', () => {
139
104
 
140
105
  describe('createFileResponse', () => {
141
106
  it('should create a response with the file content and content type', async () => {
107
+ const readFileAsBufferSpy = vi
108
+ .spyOn(fileSystem, 'readFileAsBuffer')
109
+ .mockReturnValue(Buffer.from('<svg></svg>'));
110
+
142
111
  const response = await responseFactory.createFileResponse(
143
112
  FIXTURE_EXISTING_SVG_FILE_IN_DIST_PATH,
144
113
  'image/svg+xml',
145
114
  );
115
+ readFileAsBufferSpy.mockRestore();
116
+ if (!response) {
117
+ throw new Error('Expected static file response');
118
+ }
146
119
  expect(response.headers.get('Content-Type')).toBe('image/svg+xml');
147
120
  });
148
121
 
149
- it('should return custom 404 page if the file does not exist', async () => {
122
+ it('should return null if the file does not exist', async () => {
150
123
  const response = await responseFactory.createFileResponse('/path/to/nonexistent.txt', 'text/plain');
151
- const body = await response.text();
152
- expect(body).toContain('<h1>404 - Page Not Found</h1>');
153
- expect(response.status).toBe(404);
124
+ expect(response).toBeNull();
154
125
  });
155
126
 
156
127
  it('should log debug for ENOENT errors', async () => {
@@ -168,18 +139,36 @@ describe('FileSystemServerResponseFactory', () => {
168
139
 
169
140
  it('should serve gzip file with Content-Encoding header when gzip is enabled', async () => {
170
141
  const cssFilePath = `${appConfig.absolutePaths.distDir}/${FIXTURE_EXISTING_CSS_FILE_IN_DIST}`;
142
+ const existsSpy = vi
143
+ .spyOn(fileSystem, 'exists')
144
+ .mockImplementation((filePath) => filePath === `${cssFilePath}.gz`);
145
+ const readFileAsBufferSpy = vi
146
+ .spyOn(fileSystem, 'readFileAsBuffer')
147
+ .mockReturnValue(Buffer.from('body{color:red}'));
171
148
  const response = await responseFactory.createFileResponse(cssFilePath, 'text/css');
149
+ existsSpy.mockRestore();
150
+ readFileAsBufferSpy.mockRestore();
172
151
 
152
+ if (!response) {
153
+ throw new Error('Expected gzip file response');
154
+ }
173
155
  expect(response.headers.get('Content-Type')).toBe('text/css');
174
156
  expect(response.headers.get('Content-Encoding')).toBe('gzip');
175
157
  });
176
158
 
177
159
  it('should not set Content-Encoding header for non-gzip content types', async () => {
160
+ const readFileAsBufferSpy = vi
161
+ .spyOn(fileSystem, 'readFileAsBuffer')
162
+ .mockReturnValue(Buffer.from('<svg></svg>'));
178
163
  const response = await responseFactory.createFileResponse(
179
164
  FIXTURE_EXISTING_SVG_FILE_IN_DIST_PATH,
180
165
  'image/svg+xml',
181
166
  );
167
+ readFileAsBufferSpy.mockRestore();
182
168
 
169
+ if (!response) {
170
+ throw new Error('Expected non-gzip file response');
171
+ }
183
172
  expect(response.headers.get('Content-Type')).toBe('image/svg+xml');
184
173
  expect(response.headers.get('Content-Encoding')).toBeNull();
185
174
  });
@@ -1,26 +1,16 @@
1
1
  import { STATUS_MESSAGE } from '../../config/constants.ts';
2
2
  import { appLogger } from '../../global/app-logger.ts';
3
- import type { EcoPagesAppConfig, FileSystemServerOptions } from '../../types/internal-types.ts';
3
+ import type { FileSystemServerOptions } from '../../types/internal-types.ts';
4
4
  import type { RouteRendererBody } from '../../types/public-types.ts';
5
- import type { RouteRendererFactory } from '../../route-renderer/route-renderer.ts';
6
5
  import { fileSystem } from '@ecopages/file-system';
7
6
 
7
+ /**
8
+ * Builds HTTP responses for static files and shared file-system fallbacks.
9
+ */
8
10
  export class FileSystemServerResponseFactory {
9
- private appConfig: EcoPagesAppConfig;
10
- private routeRendererFactory: RouteRendererFactory;
11
11
  private options: FileSystemServerOptions;
12
12
 
13
- constructor({
14
- appConfig,
15
- routeRendererFactory,
16
- options,
17
- }: {
18
- appConfig: EcoPagesAppConfig;
19
- routeRendererFactory: RouteRendererFactory;
20
- options: FileSystemServerOptions;
21
- }) {
22
- this.appConfig = appConfig;
23
- this.routeRendererFactory = routeRendererFactory;
13
+ constructor({ options }: { options: FileSystemServerOptions }) {
24
14
  this.options = options;
25
15
  }
26
16
 
@@ -51,26 +41,11 @@ export class FileSystemServerResponseFactory {
51
41
  });
52
42
  }
53
43
 
54
- async createCustomNotFoundResponse() {
55
- const error404TemplatePath = this.appConfig.absolutePaths.error404TemplatePath;
56
-
57
- try {
58
- fileSystem.verifyFileExists(error404TemplatePath);
59
- } catch {
60
- appLogger.debug(
61
- 'Custom 404 template not found, falling back to default 404 response',
62
- error404TemplatePath,
63
- );
64
- return this.createDefaultNotFoundResponse();
65
- }
66
-
67
- const routeRenderer = this.routeRendererFactory.createRenderer(error404TemplatePath);
68
-
69
- const result = await routeRenderer.createRoute({
70
- file: error404TemplatePath,
71
- });
72
-
73
- return await this.createResponseWithBody(result.body, {
44
+ /**
45
+ * Wraps already-rendered HTML in a 404 response envelope.
46
+ */
47
+ async createHtmlNotFoundResponse(body: RouteRendererBody) {
48
+ return await this.createResponseWithBody(body, {
74
49
  status: 404,
75
50
  statusText: STATUS_MESSAGE[404],
76
51
  headers: {
@@ -79,7 +54,10 @@ export class FileSystemServerResponseFactory {
79
54
  });
80
55
  }
81
56
 
82
- async createFileResponse(filePath: string, contentType: string) {
57
+ /**
58
+ * Reads a static file response, returning `null` when the file is missing.
59
+ */
60
+ async createFileResponse(filePath: string, contentType: string): Promise<Response | null> {
83
61
  try {
84
62
  let file: Buffer;
85
63
  const contentEncodingHeader: HeadersInit = {};
@@ -112,7 +90,7 @@ export class FileSystemServerResponseFactory {
112
90
  } else {
113
91
  appLogger.error('Error reading file', filePath, err);
114
92
  }
115
- return this.createCustomNotFoundResponse();
93
+ return null;
116
94
  }
117
95
  }
118
96
  }
@@ -1,11 +1,11 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
+ import { fileSystem } from '@ecopages/file-system';
2
3
  import path from 'node:path';
3
4
  import { APP_TEST_ROUTES, FIXTURE_APP_PROJECT_DIR, INDEX_TEMPLATE_FILE } from '../../../__fixtures__/constants.ts';
4
5
  import { ConfigBuilder } from '../../config/config-builder.ts';
5
6
  import type { MatchResult } from '../../types/internal-types.ts';
6
7
  import { RouteRendererFactory } from '../../route-renderer/route-renderer.ts';
7
- import { FSRouter } from '../../router/server/fs-router.ts';
8
- import { FSRouterScanner } from '../../router/server/fs-router-scanner.ts';
8
+ import { RouteRegistry } from '../../router/server/route-registry.ts';
9
9
  import { MemoryCacheStore } from '../../services/cache/memory-cache-store.ts';
10
10
  import { PageCacheService } from '../../services/cache/page-cache-service.ts';
11
11
  import { FileSystemServerResponseFactory } from './fs-server-response-factory.ts';
@@ -18,21 +18,18 @@ for (const integration of appConfig.integrations) {
18
18
  integration.setRuntimeOrigin(appConfig.baseUrl);
19
19
  }
20
20
 
21
- const scanner = new FSRouterScanner({
22
- dir: path.join(appConfig.rootDir, appConfig.srcDir, appConfig.pagesDir),
21
+ const router = new RouteRegistry({
22
+ pagesDir: path.join(appConfig.rootDir, appConfig.srcDir, appConfig.pagesDir),
23
23
  appConfig,
24
24
  origin: appConfig.baseUrl,
25
25
  templatesExt: appConfig.templatesExt,
26
- options: {
27
- buildMode: false,
26
+ buildMode: false,
27
+ pageModuleAdapter: {
28
+ loadPageModule: vi.fn(async () => ({})),
28
29
  },
29
30
  });
30
31
 
31
- const router = new FSRouter({
32
- origin: appConfig.baseUrl,
33
- assetPrefix: path.join(appConfig.rootDir, appConfig.distDir),
34
- scanner,
35
- });
32
+ await router.init();
36
33
 
37
34
  const routeRendererFactory = new RouteRendererFactory({
38
35
  appConfig,
@@ -40,8 +37,6 @@ const routeRendererFactory = new RouteRendererFactory({
40
37
  });
41
38
 
42
39
  const fileSystemResponseFactory = new FileSystemServerResponseFactory({
43
- appConfig,
44
- routeRendererFactory,
45
40
  options: {
46
41
  watchMode: false,
47
42
  },
@@ -51,6 +46,7 @@ describe('FileSystemResponseMatcher', () => {
51
46
  describe('without cache service', () => {
52
47
  const matcherWithoutCache = new FileSystemResponseMatcher({
53
48
  appConfig,
49
+ assetPrefix: path.join(appConfig.rootDir, appConfig.distDir),
54
50
  router,
55
51
  routeRendererFactory,
56
52
  fileSystemResponseFactory,
@@ -65,9 +61,12 @@ describe('FileSystemResponseMatcher', () => {
65
61
 
66
62
  it('should handle match with disabled cache headers', async () => {
67
63
  const match: MatchResult = {
68
- kind: 'exact',
69
- pathname: APP_TEST_ROUTES.index,
70
- filePath: INDEX_TEMPLATE_FILE,
64
+ requestedPathname: APP_TEST_ROUTES.index,
65
+ templateRoute: {
66
+ kind: 'exact',
67
+ pathname: APP_TEST_ROUTES.index,
68
+ filePath: INDEX_TEMPLATE_FILE,
69
+ },
71
70
  params: {},
72
71
  query: {},
73
72
  };
@@ -90,6 +89,7 @@ describe('FileSystemResponseMatcher', () => {
90
89
 
91
90
  const matcherWithCache = new FileSystemResponseMatcher({
92
91
  appConfig,
92
+ assetPrefix: path.join(appConfig.rootDir, appConfig.distDir),
93
93
  router,
94
94
  routeRendererFactory,
95
95
  fileSystemResponseFactory,
@@ -103,9 +103,12 @@ describe('FileSystemResponseMatcher', () => {
103
103
 
104
104
  it('should return X-Cache header on first request (MISS)', async () => {
105
105
  const match: MatchResult = {
106
- kind: 'exact',
107
- pathname: '/cache-test-miss',
108
- filePath: INDEX_TEMPLATE_FILE,
106
+ requestedPathname: '/cache-test-miss',
107
+ templateRoute: {
108
+ kind: 'exact',
109
+ pathname: '/cache-test-miss',
110
+ filePath: INDEX_TEMPLATE_FILE,
111
+ },
109
112
  params: {},
110
113
  query: {},
111
114
  };
@@ -117,9 +120,12 @@ describe('FileSystemResponseMatcher', () => {
117
120
  it('should return X-Cache HIT on second request to same path', async () => {
118
121
  const uniquePath = `/cache-test-hit-${Date.now()}`;
119
122
  const match: MatchResult = {
120
- kind: 'exact',
121
- pathname: uniquePath,
122
- filePath: INDEX_TEMPLATE_FILE,
123
+ requestedPathname: uniquePath,
124
+ templateRoute: {
125
+ kind: 'exact',
126
+ pathname: uniquePath,
127
+ filePath: INDEX_TEMPLATE_FILE,
128
+ },
123
129
  params: {},
124
130
  query: {},
125
131
  };
@@ -133,16 +139,22 @@ describe('FileSystemResponseMatcher', () => {
133
139
 
134
140
  it('should cache different paths separately', async () => {
135
141
  const match1: MatchResult = {
136
- kind: 'exact',
137
- pathname: '/path-a',
138
- filePath: INDEX_TEMPLATE_FILE,
142
+ requestedPathname: '/path-a',
143
+ templateRoute: {
144
+ kind: 'exact',
145
+ pathname: '/path-a',
146
+ filePath: INDEX_TEMPLATE_FILE,
147
+ },
139
148
  params: {},
140
149
  query: {},
141
150
  };
142
151
  const match2: MatchResult = {
143
- kind: 'exact',
144
- pathname: '/path-b',
145
- filePath: INDEX_TEMPLATE_FILE,
152
+ requestedPathname: '/path-b',
153
+ templateRoute: {
154
+ kind: 'exact',
155
+ pathname: '/path-b',
156
+ filePath: INDEX_TEMPLATE_FILE,
157
+ },
146
158
  params: {},
147
159
  query: {},
148
160
  };
@@ -157,16 +169,22 @@ describe('FileSystemResponseMatcher', () => {
157
169
  it('should include query params in cache key', async () => {
158
170
  const basePath = `/search-${Date.now()}`;
159
171
  const matchWithQuery: MatchResult = {
160
- kind: 'exact',
161
- pathname: basePath,
162
- filePath: INDEX_TEMPLATE_FILE,
172
+ requestedPathname: basePath,
173
+ templateRoute: {
174
+ kind: 'exact',
175
+ pathname: basePath,
176
+ filePath: INDEX_TEMPLATE_FILE,
177
+ },
163
178
  params: {},
164
179
  query: { q: 'test' },
165
180
  };
166
181
  const matchWithDifferentQuery: MatchResult = {
167
- kind: 'exact',
168
- pathname: basePath,
169
- filePath: INDEX_TEMPLATE_FILE,
182
+ requestedPathname: basePath,
183
+ templateRoute: {
184
+ kind: 'exact',
185
+ pathname: basePath,
186
+ filePath: INDEX_TEMPLATE_FILE,
187
+ },
170
188
  params: {},
171
189
  query: { q: 'other' },
172
190
  };
@@ -189,6 +207,7 @@ describe('FileSystemResponseMatcher', () => {
189
207
 
190
208
  const dynamicMatcher = new FileSystemResponseMatcher({
191
209
  appConfig,
210
+ assetPrefix: path.join(appConfig.rootDir, appConfig.distDir),
192
211
  router,
193
212
  routeRendererFactory,
194
213
  fileSystemResponseFactory,
@@ -198,9 +217,12 @@ describe('FileSystemResponseMatcher', () => {
198
217
 
199
218
  it('should bypass cache entirely for dynamic strategy', async () => {
200
219
  const match: MatchResult = {
201
- kind: 'exact',
202
- pathname: '/dynamic-page',
203
- filePath: INDEX_TEMPLATE_FILE,
220
+ requestedPathname: '/dynamic-page',
221
+ templateRoute: {
222
+ kind: 'exact',
223
+ pathname: '/dynamic-page',
224
+ filePath: INDEX_TEMPLATE_FILE,
225
+ },
204
226
  params: {},
205
227
  query: {},
206
228
  };
@@ -217,6 +239,7 @@ describe('FileSystemResponseMatcher', () => {
217
239
  describe('handleNoMatch content type behavior', () => {
218
240
  const matcher = new FileSystemResponseMatcher({
219
241
  appConfig,
242
+ assetPrefix: path.join(appConfig.rootDir, appConfig.distDir),
220
243
  router,
221
244
  routeRendererFactory,
222
245
  fileSystemResponseFactory,
@@ -247,7 +270,9 @@ describe('FileSystemResponseMatcher', () => {
247
270
  });
248
271
 
249
272
  it('should serve text/plain files from disk', async () => {
273
+ const readFileAsBuffer = vi.spyOn(fileSystem, 'readFileAsBuffer').mockReturnValue(Buffer.from('robots'));
250
274
  const response = await matcher.handleNoMatch('/robots.txt');
275
+ readFileAsBuffer.mockRestore();
251
276
  expect(response.headers.get('Content-Type')).toBe('text/plain');
252
277
  });
253
278
 
@@ -262,6 +287,7 @@ describe('FileSystemResponseMatcher', () => {
262
287
  it('should inspect page modules through the owning route renderer', async () => {
263
288
  const matcher = new FileSystemResponseMatcher({
264
289
  appConfig,
290
+ assetPrefix: path.join(appConfig.rootDir, appConfig.distDir),
265
291
  router,
266
292
  routeRendererFactory,
267
293
  fileSystemResponseFactory,
@@ -269,14 +295,14 @@ describe('FileSystemResponseMatcher', () => {
269
295
 
270
296
  const loadPageModule = vi.fn(async () => ({}));
271
297
  (matcher as any).routeRendererFactory = {
272
- createRenderer: vi.fn(() => ({
298
+ getPageRenderer: vi.fn(() => ({
273
299
  loadPageModule,
274
300
  })),
275
301
  };
276
302
 
277
303
  await (matcher as any).importPageModule(INDEX_TEMPLATE_FILE);
278
304
 
279
- expect((matcher as any).routeRendererFactory.createRenderer).toHaveBeenCalledWith(INDEX_TEMPLATE_FILE);
305
+ expect((matcher as any).routeRendererFactory.getPageRenderer).toHaveBeenCalledWith(INDEX_TEMPLATE_FILE);
280
306
  expect(loadPageModule).toHaveBeenCalledWith(INDEX_TEMPLATE_FILE, {
281
307
  cacheScope: 'request-metadata',
282
308
  });