diff --git a/.changeset/mighty-bears-rescue.md b/.changeset/mighty-bears-rescue.md new file mode 100644 index 000000000000..d5362d5c8685 --- /dev/null +++ b/.changeset/mighty-bears-rescue.md @@ -0,0 +1,6 @@ +--- +'@modern-js/prod-server': minor +--- + +feat: support `x-render-cache` response headers when open ssr cache +feat: 当开启 ssr 缓存时,支持 `x-render-cache` 响应头 diff --git a/packages/server/prod-server/src/libs/render/ssr.ts b/packages/server/prod-server/src/libs/render/ssr.ts index ad5108aed9a3..e1d293ddb4b1 100644 --- a/packages/server/prod-server/src/libs/render/ssr.ts +++ b/packages/server/prod-server/src/libs/render/ssr.ts @@ -93,7 +93,11 @@ export const render = async ( const serverRender: RenderFunction = bundleJSContent[SERVER_RENDER_FUNCTION_NAME]; - const content = await ssrCache(ctx.req, serverRender, context); + const { data: content, status: cacheStatus } = await ssrCache( + ctx.req, + serverRender, + context, + ); const { url, status = 302 } = context.redirection; @@ -106,10 +110,14 @@ export const render = async ( }; } + const headers: Record = {}; + cacheStatus && (headers['x-render-cache'] = cacheStatus); + if (typeof content === 'string') { return { content: injectServerData(content, ctx), contentType: mime.contentType('html') as string, + headers, }; } else { let contentStream = injectServerDataStream(content, ctx); @@ -130,6 +138,7 @@ export const render = async ( content: '', contentStream, contentType: mime.contentType('html') as string, + headers, }; } }; diff --git a/packages/server/prod-server/src/libs/render/ssrCache/index.ts b/packages/server/prod-server/src/libs/render/ssrCache/index.ts index f9c34e648df9..3408aefe362a 100644 --- a/packages/server/prod-server/src/libs/render/ssrCache/index.ts +++ b/packages/server/prod-server/src/libs/render/ssrCache/index.ts @@ -1,5 +1,5 @@ import { IncomingMessage } from 'http'; -import { Transform, type Readable } from 'stream'; +import { Transform } from 'stream'; import { CacheControl, CacheOption, @@ -8,7 +8,7 @@ import { import { createMemoryStorage } from '@modern-js/runtime-utils/storer'; import { RenderFunction, SSRServerContext } from '../type'; import { cacheMod } from './cacheMod'; -import { CacheManager } from './manager'; +import { CacheManager, CacheResult } from './manager'; const cacheStorage = createMemoryStorage('__ssr__cache'); @@ -16,7 +16,7 @@ export async function ssrCache( req: IncomingMessage, render: RenderFunction, ssrContext: SSRServerContext, -): Promise { +): Promise { const { customContainer, cacheOption } = cacheMod; const cacheControl = await matchCacheControl(req, cacheOption); const cacheManager = new CacheManager( @@ -28,9 +28,13 @@ export async function ssrCache( } else { const renderResult = await render(ssrContext); if (!renderResult) { - return ''; + return { + data: '', + }; } else if (typeof renderResult === 'string') { - return renderResult; + return { + data: renderResult, + }; } else { const stream = new Transform({ write(chunk, _, callback) { @@ -38,7 +42,10 @@ export async function ssrCache( callback(); }, }); - return renderResult(stream); + const data = await renderResult(stream); + return { + data, + }; } } } diff --git a/packages/server/prod-server/src/libs/render/ssrCache/manager.ts b/packages/server/prod-server/src/libs/render/ssrCache/manager.ts index 17ec4bdf4d1d..07bc42f5601c 100644 --- a/packages/server/prod-server/src/libs/render/ssrCache/manager.ts +++ b/packages/server/prod-server/src/libs/render/ssrCache/manager.ts @@ -3,6 +3,12 @@ import { Readable, Transform } from 'stream'; import { CacheControl, Container } from '@modern-js/types'; import { RenderFunction, SSRServerContext } from '../type'; +type CacheStatus = 'hit' | 'stale' | 'expired' | 'miss'; +export type CacheResult = { + data: string | Readable; + status?: CacheStatus; +}; + interface CacheStruct { val: string; cursor: number; @@ -20,7 +26,7 @@ export class CacheManager { cacheControl: CacheControl, render: RenderFunction, ssrContext: SSRServerContext, - ): Promise { + ): Promise { const key = this.computedKey(req, cacheControl); const value = await this.container.get(key); @@ -34,20 +40,20 @@ export class CacheManager { if (interval <= maxAge) { // the cache is validate - return cache.val; + return { data: cache.val, status: 'hit' }; } else if (interval <= staleWhileRevalidate + maxAge) { // the cache is stale while revalidate // we shouldn't await this promise. this.processCache(key, render, ssrContext, ttl); - return cache.val; + return { data: cache.val, status: 'stale' }; } else { // the cache is invalidate - return this.processCache(key, render, ssrContext, ttl); + return this.processCache(key, render, ssrContext, ttl, 'expired'); } } else { - return this.processCache(key, render, ssrContext, ttl); + return this.processCache(key, render, ssrContext, ttl, 'miss'); } } @@ -56,11 +62,12 @@ export class CacheManager { render: RenderFunction, ssrContext: SSRServerContext, ttl: number, + status?: CacheStatus, ) { const renderResult = await render(ssrContext); if (!renderResult) { - return ''; + return { data: '' }; } else if (typeof renderResult === 'string') { const current = Date.now(); const cache: CacheStruct = { @@ -68,7 +75,7 @@ export class CacheManager { cursor: current, }; await this.container.set(key, JSON.stringify(cache), { ttl }); - return renderResult; + return { data: renderResult, status }; } else { let html = ''; const stream = new Transform({ @@ -88,7 +95,12 @@ export class CacheManager { this.container.set(key, JSON.stringify(cache), { ttl }); }); - return renderResult(stream); + const readable = await renderResult(stream); + + return { + data: readable, + status, + }; } } diff --git a/packages/server/prod-server/src/server/modernServer.ts b/packages/server/prod-server/src/server/modernServer.ts index 6dda60880264..611477c0d1d5 100644 --- a/packages/server/prod-server/src/server/modernServer.ts +++ b/packages/server/prod-server/src/server/modernServer.ts @@ -590,7 +590,15 @@ export class ModernServer implements ModernServerInterface { return; } - const { contentStream: responseStream } = renderResult; + const { contentStream: responseStream, headers } = renderResult; + + if (headers) { + for (const name in headers) { + const value = headers[name]; + res.setHeader(name, value); + } + } + let { content: response } = renderResult; if (route.entryName && responseStream) { responseStream.pipe(res); diff --git a/packages/server/prod-server/src/type.ts b/packages/server/prod-server/src/type.ts index 828250905868..6e59173ad4b3 100644 --- a/packages/server/prod-server/src/type.ts +++ b/packages/server/prod-server/src/type.ts @@ -58,6 +58,7 @@ export type RenderResult = { contentType: string; contentStream?: Readable; statusCode?: number; + headers?: Record; redirect?: boolean; }; diff --git a/packages/server/prod-server/tests/ssrCache.test.ts b/packages/server/prod-server/tests/ssrCache.test.ts index 1e10809ed5d5..599f6cc35c00 100644 --- a/packages/server/prod-server/tests/ssrCache.test.ts +++ b/packages/server/prod-server/tests/ssrCache.test.ts @@ -14,21 +14,21 @@ function sleep(timeout: number) { class MyContainer implements Container { cache: Map = new Map(); - get(key: string): string | undefined { + async get(key: string) { return this.cache.get(key); } - set(key: string, value: string) { + async set(key: string, value: string) { this.cache.set(key, value); return this; } - has(key: string) { + async has(key: string) { return this.cache.has(key); } - delete(key: string): boolean { + async delete(key: string) { return this.cache.delete(key); } } @@ -62,7 +62,7 @@ describe('test cacheManager', () => { ssrContext, ); - expect(result1).toEqual(`Hello_0`); + expect(result1.data).toEqual(`Hello_0`); await sleep(50); const result2 = await cacheManager.getCacheResult( @@ -71,7 +71,7 @@ describe('test cacheManager', () => { render, ssrContext, ); - expect(result2).toEqual(`Hello_0`); + expect(result2.data).toEqual(`Hello_0`); await sleep(100); const result3 = await cacheManager.getCacheResult( @@ -80,7 +80,7 @@ describe('test cacheManager', () => { render, ssrContext, ); - expect(result3).toEqual(`Hello_0`); + expect(result3.data).toEqual(`Hello_0`); }); it('should revalidate the cache', async () => { @@ -104,7 +104,7 @@ describe('test cacheManager', () => { render, ssrContext, ); - expect(result1).toEqual(`Hello_0`); + expect(result1.data).toEqual(`Hello_0`); await sleep(150); const result2 = await cacheManager.getCacheResult( @@ -113,7 +113,7 @@ describe('test cacheManager', () => { render, ssrContext, ); - expect(result2).toEqual(`Hello_0`); + expect(result2.data).toEqual(`Hello_0`); await sleep(50); const result3 = await cacheManager.getCacheResult( @@ -122,7 +122,7 @@ describe('test cacheManager', () => { render, ssrContext, ); - expect(result3).toEqual(`Hello_1`); + expect(result3.data).toEqual(`Hello_1`); }); it('should invalidate the ssr, then render on next req', async () => { @@ -146,7 +146,7 @@ describe('test cacheManager', () => { render, ssrContext, ); - expect(result1).toEqual(`Hello_0`); + expect(result1.data).toEqual(`Hello_0`); await sleep(600); const result2 = await cacheManager.getCacheResult( @@ -155,7 +155,7 @@ describe('test cacheManager', () => { render, ssrContext, ); - expect(result2).toEqual(`Hello_1`); + expect(result2.data).toEqual(`Hello_1`); await sleep(600); const result3 = await cacheManager.getCacheResult( @@ -164,6 +164,6 @@ describe('test cacheManager', () => { render, ssrContext, ); - expect(result3).toEqual(`Hello_2`); + expect(result3.data).toEqual(`Hello_2`); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3e197288596..e38acf83b859 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7811,6 +7811,9 @@ importers: '@types/node': specifier: ^14 version: 14.18.35 + axios: + specifier: ^1.6.0 + version: 1.6.7 tests/integration/ssr/fixtures/base: dependencies: @@ -18332,7 +18335,7 @@ packages: /axios@0.27.2: resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} dependencies: - follow-redirects: 1.15.1 + follow-redirects: 1.15.5 form-data: 4.0.0 transitivePeerDependencies: - debug @@ -18347,6 +18350,15 @@ packages: transitivePeerDependencies: - debug + /axios@1.6.7: + resolution: {integrity: sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==} + dependencies: + follow-redirects: 1.15.5 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + /b-tween@0.3.3: resolution: {integrity: sha512-oEHegcRpA7fAuc9KC4nktucuZn2aS8htymCPcP3qkEGPqiBH+GfqtqoG2l7LxHngg6O0HFM7hOeOYExl1Oz4ZA==} @@ -22182,6 +22194,15 @@ packages: debug: optional: true + /follow-redirects@1.15.5: + resolution: {integrity: sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} dependencies: @@ -23290,7 +23311,7 @@ packages: engines: {node: '>=8.0.0'} dependencies: eventemitter3: 4.0.7 - follow-redirects: 1.15.1 + follow-redirects: 1.15.5 requires-port: 1.0.0 transitivePeerDependencies: - debug @@ -26733,7 +26754,7 @@ packages: '@yarnpkg/lockfile': 1.1.0 '@yarnpkg/parsers': 3.0.0-rc.46 '@zkochan/js-yaml': 0.0.6 - axios: 1.6.0 + axios: 1.6.7 chalk: 4.1.2 cli-cursor: 3.1.0 cli-spinners: 2.6.1 diff --git a/tests/integration/ssr/package.json b/tests/integration/ssr/package.json index ccbf7dd294c6..b39e7bf0b080 100644 --- a/tests/integration/ssr/package.json +++ b/tests/integration/ssr/package.json @@ -4,6 +4,7 @@ "version": "2.9.0", "dependencies": { "@types/jest": "^29", - "@types/node": "^14" + "@types/node": "^14", + "axios": "^1.6.0" } } diff --git a/tests/integration/ssr/tests/base.test.ts b/tests/integration/ssr/tests/base.test.ts index e2982896c96d..3ed46d5d34f6 100644 --- a/tests/integration/ssr/tests/base.test.ts +++ b/tests/integration/ssr/tests/base.test.ts @@ -2,6 +2,7 @@ import dns from 'node:dns'; import path, { join } from 'path'; import puppeteer, { Browser, Page } from 'puppeteer'; import { fs } from '@modern-js/utils'; +import axios from 'axios'; import { launchApp, getPort, @@ -124,4 +125,11 @@ describe('Traditional SSR', () => { const content1 = await page.content(); expect(content1).toMatch(result); }); + + test('x-render-cache http header', async () => { + const response = await axios.get(`http://localhost:${appPort}`); + + const { headers } = response; + expect(Boolean(headers['x-render-cache'])).toBeTruthy(); + }); });