Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: add etag caching to index html #278

Merged
merged 1 commit into from
Dec 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,30 +1,50 @@
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'
import * as CrossOriginResourcePolicy from '../CrossOriginResourcePolicy/CrossOriginResourcePolicy.ts'
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<Response> => {
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()
}
}
98 changes: 98 additions & 0 deletions packages/preview-process/test/HandleIndexHtml.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<h1>Test Content</h1>',
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('<h1>Test Content</h1>')
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('<h1>Test Content</h1>')
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: '<h1>Content 1</h1>' }
const response1 = await HandleIndexHtml.handleIndexHtml(request, options1)
const etag1 = response1.headers.get('ETag')

const options2 = { ...defaultOptions, iframeContent: '<h1>Content 2</h1>' }
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')
})