@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.
- package/README.md +63 -7
- package/package.json +4 -47
- package/src/adapters/bun/create-app.ts +54 -2
- package/src/adapters/bun/hmr-manager.test.ts +0 -2
- package/src/adapters/bun/hmr-manager.ts +1 -24
- package/src/adapters/bun/server-adapter.ts +30 -4
- package/src/adapters/node/node-hmr-manager.test.ts +0 -2
- package/src/adapters/node/node-hmr-manager.ts +2 -25
- package/src/adapters/shared/explicit-static-render-preparation.ts +58 -0
- package/src/adapters/shared/explicit-static-route-matcher.test.ts +6 -6
- package/src/adapters/shared/explicit-static-route-matcher.ts +22 -31
- package/src/adapters/shared/file-route-middleware-pipeline.test.ts +5 -10
- package/src/adapters/shared/file-route-middleware-pipeline.ts +8 -17
- package/src/adapters/shared/fs-server-response-factory.test.ts +32 -43
- package/src/adapters/shared/fs-server-response-factory.ts +15 -37
- package/src/adapters/shared/fs-server-response-matcher.test.ts +65 -39
- package/src/adapters/shared/fs-server-response-matcher.ts +94 -43
- package/src/adapters/shared/hmr-manager.contract.test.ts +0 -4
- package/src/adapters/shared/render-context.ts +3 -3
- package/src/adapters/shared/server-adapter.test.ts +53 -0
- package/src/adapters/shared/server-adapter.ts +228 -159
- package/src/adapters/shared/server-route-handler.test.ts +6 -5
- package/src/adapters/shared/server-route-handler.ts +4 -4
- package/src/adapters/shared/server-static-builder.test.ts +4 -4
- package/src/adapters/shared/server-static-builder.ts +4 -4
- package/src/config/README.md +1 -1
- package/src/config/config-builder.test.ts +0 -1
- package/src/config/config-builder.ts +2 -7
- package/src/dev/host-runtime.ts +34 -0
- package/src/eco/eco.browser.test.ts +2 -2
- package/src/eco/eco.browser.ts +2 -2
- package/src/eco/eco.test.ts +6 -6
- package/src/eco/eco.ts +12 -12
- package/src/eco/eco.types.ts +3 -3
- package/src/errors/index.ts +1 -0
- package/src/hmr/client/hmr-runtime.ts +4 -2
- package/src/hmr/strategies/js-hmr-strategy.test.ts +0 -1
- package/src/hmr/strategies/js-hmr-strategy.ts +0 -6
- package/src/integrations/ghtml/ghtml-renderer.test.ts +7 -7
- package/src/integrations/ghtml/ghtml-renderer.ts +1 -11
- package/src/plugins/eco-component-meta-plugin.ts +0 -1
- package/src/plugins/integration-plugin.test.ts +9 -14
- package/src/plugins/integration-plugin.ts +34 -22
- package/src/plugins/processor.ts +17 -0
- package/src/route-renderer/GRAPH.md +81 -289
- package/src/route-renderer/README.md +67 -105
- package/src/route-renderer/orchestration/component-render-context.ts +45 -38
- package/src/route-renderer/orchestration/declared-ownership-graph.ts +62 -0
- package/src/route-renderer/orchestration/foreign-subtree-execution.service.ts +383 -0
- package/src/route-renderer/orchestration/integration-renderer.test.ts +118 -121
- package/src/route-renderer/orchestration/integration-renderer.ts +362 -403
- package/src/route-renderer/orchestration/ownership-planning.service.ts +97 -0
- package/src/route-renderer/orchestration/ownership-validation.service.ts +76 -0
- package/src/route-renderer/orchestration/processed-asset-dedupe.ts +1 -1
- package/src/route-renderer/orchestration/{queued-boundary-runtime.service.test.ts → queued-foreign-subtree-resolution.service.test.ts} +76 -71
- package/src/route-renderer/orchestration/{queued-boundary-runtime.service.ts → queued-foreign-subtree-resolution.service.ts} +68 -63
- package/src/route-renderer/orchestration/render-output.utils.ts +21 -13
- package/src/route-renderer/orchestration/{render-preparation.service.test.ts → route-render-orchestrator.prepare-render-options.test.ts} +160 -85
- package/src/route-renderer/orchestration/route-render-orchestrator.test.ts +265 -0
- package/src/route-renderer/orchestration/{render-preparation.service.ts → route-render-orchestrator.ts} +244 -160
- package/src/route-renderer/page-loading/component-dependency-collection.ts +9 -3
- package/src/route-renderer/page-loading/declared-asset-collection.ts +2 -5
- package/src/route-renderer/page-loading/dependency-resolver.test.ts +107 -11
- package/src/route-renderer/page-loading/dependency-resolver.ts +6 -12
- package/src/route-renderer/page-loading/ecopages-virtual-imports.ts +1 -1
- package/src/route-renderer/page-loading/lazy-entry-collection.ts +1 -1
- package/src/route-renderer/page-loading/lazy-trigger-planning.ts +1 -1
- package/src/route-renderer/page-loading/module-declaration-aggregation.ts +1 -1
- package/src/route-renderer/page-loading/module-declaration-scripts.ts +1 -1
- package/src/route-renderer/page-loading/page-dependency-bundling.ts +105 -66
- package/src/route-renderer/route-renderer.ts +28 -31
- package/src/router/README.md +16 -19
- package/src/router/server/route-registry.test.ts +176 -0
- package/src/router/server/route-registry.ts +382 -0
- package/src/services/README.md +1 -2
- package/src/services/assets/asset-processing-service/asset-dependency-keys.ts +1 -1
- package/src/services/assets/asset-processing-service/asset-processing.service.test.ts +1 -4
- package/src/services/assets/asset-processing-service/asset-processing.service.ts +1 -2
- package/src/services/assets/asset-processing-service/assets.types.ts +3 -0
- package/src/services/assets/asset-processing-service/grouped-content-bundles.ts +1 -1
- package/src/services/assets/asset-processing-service/index.ts +1 -0
- package/src/{route-renderer/orchestration/page-packaging.service.test.ts → services/assets/asset-processing-service/page-package.test.ts} +38 -14
- package/src/services/assets/asset-processing-service/page-package.ts +93 -0
- package/src/services/assets/asset-processing-service/processors/base/base-script-processor.ts +4 -5
- package/src/services/assets/asset-processing-service/processors/script/content-script.processor.test.ts +13 -10
- package/src/services/assets/asset-processing-service/processors/script/content-script.processor.ts +3 -0
- package/src/services/assets/asset-processing-service/processors/script/file-script.processor.ts +6 -0
- package/src/services/assets/asset-processing-service/processors/script/node-module-script.processor.ts +2 -0
- package/src/services/assets/asset-processing-service/processors/stylesheet/content-stylesheet.processor.ts +1 -0
- package/src/services/assets/asset-processing-service/processors/stylesheet/file-stylesheet.processor.ts +2 -0
- package/src/services/assets/asset-processing-service/ungrouped-dependency-processing.ts +1 -1
- package/src/services/html/html-transformer.service.test.ts +1 -4
- package/src/services/module-loading/app-server-module-transpiler.service.ts +1 -3
- package/src/services/module-loading/node-bootstrap-plugin.ts +17 -3
- package/src/services/module-loading/page-module-import.service.ts +0 -1
- package/src/services/module-loading/source-module-support.ts +1 -1
- package/src/static-site-generator/static-site-generator.test.ts +124 -32
- package/src/static-site-generator/static-site-generator.ts +168 -185
- package/src/types/internal-types.ts +13 -12
- package/src/types/public-types.ts +55 -39
- package/src/watchers/project-watcher.test-helpers.ts +4 -3
- package/src/route-renderer/orchestration/boundary-planning.service.ts +0 -146
- package/src/route-renderer/orchestration/page-packaging.service.ts +0 -85
- package/src/route-renderer/orchestration/render-execution.service.test.ts +0 -196
- package/src/route-renderer/orchestration/render-execution.service.ts +0 -182
- package/src/route-renderer/orchestration/route-shell-composer.service.ts +0 -162
- package/src/router/server/fs-router-scanner.test.ts +0 -83
- package/src/router/server/fs-router-scanner.ts +0 -224
- package/src/router/server/fs-router.test.ts +0 -214
- package/src/router/server/fs-router.ts +0 -122
- 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 {
|
|
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:
|
|
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:
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
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:
|
|
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 {
|
|
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:
|
|
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
|
|
54
|
-
*
|
|
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
|
-
}):
|
|
65
|
-
const context:
|
|
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:
|
|
109
|
-
context:
|
|
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('
|
|
106
|
-
it('should create
|
|
107
|
-
const
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
|
|
26
|
+
buildMode: false,
|
|
27
|
+
pageModuleAdapter: {
|
|
28
|
+
loadPageModule: vi.fn(async () => ({})),
|
|
28
29
|
},
|
|
29
30
|
});
|
|
30
31
|
|
|
31
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
});
|