diff --git a/packages/preview-process/src/parts/HandleIndexHtml/HandleIndexHtml.ts b/packages/preview-process/src/parts/HandleIndexHtml/HandleIndexHtml.ts index 93c69e75..30890f17 100644 --- a/packages/preview-process/src/parts/HandleIndexHtml/HandleIndexHtml.ts +++ b/packages/preview-process/src/parts/HandleIndexHtml/HandleIndexHtml.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import type { HandlerOptions } from '../HandlerOptions/HandlerOptions.ts' import type { RequestOptions } from '../RequestOptions/RequestOptions.ts' import * as CrossOriginEmbedderPolicy from '../CrossOriginEmbedderPolicy/CrossOriginEmbedderPolicy.ts' @@ -5,26 +6,45 @@ import * as CrossOriginResourcePolicy from '../CrossOriginResourcePolicy/CrossOr import * as GetContentSecurityPolicyDocument from '../GetContentSecurityPolicyDocument/GetContentSecurityPolicyDocument.ts' import * as GetContentType from '../GetContentType/GetContentType.ts' import * as HttpHeader from '../HttpHeader/HttpHeader.ts' -import { NotFoundResponse } from '../Responses/NotFoundResponse.ts' +import * as MatchesEtag from '../MatchesEtag/MatchesEtag.ts' +import { NotModifiedResponse } from '../Responses/NotModifiedResponse.ts' +import { ServerErrorResponse } from '../Responses/ServerErrorResponse.ts' + +const generateEtag = (content: string): string => { + const hash = createHash('sha1') + hash.update(content) + return `W/"${hash.digest('hex')}"` +} export const handleIndexHtml = async (request: RequestOptions, options: HandlerOptions): Promise => { try { - const csp = GetContentSecurityPolicyDocument.getContentSecurityPolicyDocument(options.contentSecurityPolicy) + if (!options.iframeContent) { + throw new Error('iframe content is required') + } + const contentType = GetContentType.getContentType('/test/index.html') + const csp = GetContentSecurityPolicyDocument.getContentSecurityPolicyDocument(options.contentSecurityPolicy) const headers = { [HttpHeader.CrossOriginResourcePolicy]: CrossOriginResourcePolicy.CrossOrigin, [HttpHeader.CrossOriginEmbedderPolicy]: CrossOriginEmbedderPolicy.value, [HttpHeader.ContentSecurityPolicy]: csp, [HttpHeader.ContentType]: contentType, } - if (!options.iframeContent) { - throw new Error(`iframe content is required`) + + if (options.etag) { + const etag = generateEtag(options.iframeContent) + if (MatchesEtag.matchesEtag(request, etag)) { + return new NotModifiedResponse(etag) + } + // @ts-ignore + headers[HttpHeader.Etag] = etag } + return new Response(options.iframeContent, { headers, }) } catch (error) { console.error(`[preview-server] ${error}`) - return new NotFoundResponse() + return new ServerErrorResponse() } } diff --git a/packages/preview-process/test/HandleIndexHtml.test.ts b/packages/preview-process/test/HandleIndexHtml.test.ts new file mode 100644 index 00000000..ac5e6326 --- /dev/null +++ b/packages/preview-process/test/HandleIndexHtml.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from '@jest/globals' +import * as HandleIndexHtml from '../src/parts/HandleIndexHtml/HandleIndexHtml.ts' +import * as HttpStatusCode from '../src/parts/HttpStatusCode/HttpStatusCode.ts' + +const defaultOptions = { + webViewRoot: '/test', + contentSecurityPolicy: "default-src 'self'", + iframeContent: '

Test Content

', + stream: false, + etag: true, + remotePathPrefix: '/remote', +} + +test('handleIndexHtml - returns content with etag and security headers', async () => { + const request = { + method: 'GET', + path: '/index.html', + headers: {}, + } + + const response = await HandleIndexHtml.handleIndexHtml(request, defaultOptions) + expect(response.status).toBe(HttpStatusCode.Ok) + expect(await response.text()).toBe('

Test Content

') + expect(response.headers.get('ETag')).toMatch(/^W\/"[a-f0-9]+"$/) + expect(response.headers.get('Cross-Origin-Resource-Policy')).toBe('cross-origin') + expect(response.headers.get('Cross-Origin-Embedder-Policy')).toBe('require-corp') + expect(response.headers.get('Content-Security-Policy')).toBe("default-src 'self'") +}) + +test('handleIndexHtml - returns 304 with security headers when etag matches', async () => { + const request1 = { + method: 'GET', + path: '/index.html', + headers: {}, + } + const response1 = await HandleIndexHtml.handleIndexHtml(request1, defaultOptions) + const etag = response1.headers.get('ETag') + + const request2 = { + method: 'GET', + path: '/index.html', + headers: { + 'if-none-match': etag, + }, + } + const response2 = await HandleIndexHtml.handleIndexHtml(request2, defaultOptions) + expect(response2.status).toBe(HttpStatusCode.NotModified) + expect(response2.headers.get('ETag')).toBe(etag) + expect(response2.headers.get('Cross-Origin-Resource-Policy')).toBe('same-origin') +}) + +test('handleIndexHtml - returns content without etag when etag option is false', async () => { + const request = { + method: 'GET', + path: '/index.html', + headers: {}, + } + + const options = { ...defaultOptions, etag: false } + const response = await HandleIndexHtml.handleIndexHtml(request, options) + expect(response.status).toBe(HttpStatusCode.Ok) + expect(await response.text()).toBe('

Test Content

') + expect(response.headers.get('ETag')).toBeNull() + expect(response.headers.get('Cross-Origin-Resource-Policy')).toBe('cross-origin') + expect(response.headers.get('Cross-Origin-Embedder-Policy')).toBe('require-corp') +}) + +test('handleIndexHtml - returns 500 when no iframe content', async () => { + const request = { + method: 'GET', + path: '/index.html', + headers: {}, + } + + const options = { ...defaultOptions, iframeContent: '' } + const response = await HandleIndexHtml.handleIndexHtml(request, options) + expect(response.status).toBe(HttpStatusCode.ServerError) +}) + +test('handleIndexHtml - generates different etags for different content', async () => { + const request = { + method: 'GET', + path: '/index.html', + headers: {}, + } + + const options1 = { ...defaultOptions, iframeContent: '

Content 1

' } + const response1 = await HandleIndexHtml.handleIndexHtml(request, options1) + const etag1 = response1.headers.get('ETag') + + const options2 = { ...defaultOptions, iframeContent: '

Content 2

' } + const response2 = await HandleIndexHtml.handleIndexHtml(request, options2) + const etag2 = response2.headers.get('ETag') + + expect(etag1).not.toBe(etag2) + expect(response1.headers.get('Cross-Origin-Resource-Policy')).toBe('cross-origin') + expect(response2.headers.get('Cross-Origin-Resource-Policy')).toBe('cross-origin') +})