Skip to content

Commit

Permalink
fix: adds html injections to dev proxy [CRE-1203] (#6686)
Browse files Browse the repository at this point in the history
feat: [CRE-1203] added html injections to dev proxy
  • Loading branch information
smnh committed Jun 5, 2024
1 parent aba9592 commit c695931
Show file tree
Hide file tree
Showing 7 changed files with 488 additions and 631 deletions.
905 changes: 297 additions & 608 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,13 @@
"@bugsnag/js": "7.23.0",
"@fastify/static": "7.0.4",
"@netlify/blobs": "7.3.0",
"@netlify/build": "29.46.2",
"@netlify/build": "29.46.4",
"@netlify/build-info": "7.13.2",
"@netlify/config": "20.13.2",
"@netlify/config": "20.14.1",
"@netlify/edge-bundler": "12.0.1",
"@netlify/edge-functions": "2.8.1",
"@netlify/local-functions-proxy": "1.1.1",
"@netlify/zip-it-and-ship-it": "9.34.0",
"@netlify/zip-it-and-ship-it": "9.34.1",
"@octokit/rest": "20.1.1",
"@opentelemetry/api": "1.8.0",
"ansi-escapes": "7.0.0",
Expand Down Expand Up @@ -187,7 +187,7 @@
},
"devDependencies": {
"@babel/preset-react": "7.24.6",
"@netlify/eslint-config-node": "7.0.0",
"@netlify/eslint-config-node": "7.0.1",
"@netlify/functions": "2.7.0",
"@sindresorhus/slugify": "2.2.1",
"@types/fs-extra": "11.0.4",
Expand Down
1 change: 0 additions & 1 deletion src/commands/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { format } from 'util'

import { DefaultLogger, Project } from '@netlify/build-info'
import { NodeFS, NoopLogger } from '@netlify/build-info/node'
// @ts-expect-error TS(7016) FIXME: Could not find a declaration file for module '@net... Remove this comment to see the full error message
import { resolveConfig } from '@netlify/config'
import { Command, Help, Option } from 'commander'
// @ts-expect-error TS(7016) FIXME: Could not find a declaration file for module 'debu... Remove this comment to see the full error message
Expand Down
1 change: 0 additions & 1 deletion src/commands/dev/dev.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import process from 'process'

// @ts-expect-error TS(7016) FIXME: Could not find a declaration file for module '@net... Remove this comment to see the full error message
import { applyMutations } from '@netlify/config'
import { Option, OptionValues } from 'commander'

Expand Down
26 changes: 24 additions & 2 deletions src/commands/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NetlifyTOML } from '@netlify/build-info'
import type { NetlifyConfig } from "@netlify/build";
import type { NetlifyTOML } from '@netlify/build-info'
import type { NetlifyAPI } from 'netlify'

import StateConfig from '../utils/state-config.js'
Expand All @@ -14,16 +15,37 @@ export type NetlifySite = {
set id(id: string): void
}

type PatchedConfig = NetlifyTOML & {
type PatchedConfig = NetlifyTOML & Pick<NetlifyConfig, 'images'> & {
functionsDirectory?: string
build: NetlifyTOML['build'] & {
functionsSource?: string
}
dev: NetlifyTOML['dev'] & {
functions?: string
processing?: DevProcessing
}
}

type DevProcessing = {
html?: HTMLProcessing
}

type HTMLProcessing = {
injections?: HTMLInjection[]
}

type HTMLInjection = {
/**
* The location at which the `html` will be injected.
* Defaults to `before_closing_head_tag` which will inject the HTML before the </head> tag.
*/
location?: 'before_closing_head_tag' | 'before_closing_body_tag',
/**
* The injected HTML code.
*/
html: string
}

type EnvironmentVariableScope = 'builds' | 'functions' | 'runtime' | 'post_processing'
type EnvironmentVariableSource = 'account' | 'addons' | 'configFile' | 'general' | 'internal' | 'ui'

Expand Down
78 changes: 63 additions & 15 deletions src/utils/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import pFilter from 'p-filter'
import toReadableStream from 'to-readable-stream'

import { BaseCommand } from '../commands/index.js'
import { $TSFixMe } from '../commands/types.js'
import { $TSFixMe, NetlifyOptions } from '../commands/types.js'
import {
handleProxyRequest,
initializeProxy as initializeEdgeFunctionsProxy,
Expand All @@ -47,8 +47,11 @@ import { signRedirect } from './sign-redirect.js'
import { Request, Rewriter, ServerSettings } from './types.js'

const gunzip = util.promisify(zlib.gunzip)
const gzip = util.promisify(zlib.gzip)
const brotliDecompress = util.promisify(zlib.brotliDecompress)
const brotliCompress = util.promisify(zlib.brotliCompress)
const deflate = util.promisify(zlib.deflate)
const inflate = util.promisify(zlib.inflate)
const shouldGenerateETag = Symbol('Internal: response should generate ETag')

const decompressResponseBody = async function (body: Buffer, contentEncoding = ''): Promise<Buffer> {
Expand All @@ -58,12 +61,48 @@ const decompressResponseBody = async function (body: Buffer, contentEncoding = '
case 'br':
return await brotliDecompress(body)
case 'deflate':
return await deflate(body)
return await inflate(body)
default:
return body
}
}

const compressResponseBody = async function (body: string, contentEncoding = ''): Promise<Buffer> {
switch (contentEncoding) {
case 'gzip':
return await gzip(body)
case 'br':
return await brotliCompress(body)
case 'deflate':
return await deflate(body)
default:
return Buffer.from(body, 'utf8')
}
}

type HTMLInjections = NonNullable<NonNullable<NetlifyOptions['config']['dev']['processing']>['html']>['injections']

const injectHtml = async function (
responseBody: Buffer,
proxyRes: http.IncomingMessage,
htmlInjections: HTMLInjections,
): Promise<Buffer> {
const decompressedBody: Buffer = await decompressResponseBody(responseBody, proxyRes.headers['content-encoding'])
const bodyWithInjections: string = (htmlInjections ?? []).reduce((accum, htmlInjection) => {
if (!htmlInjection.html || typeof htmlInjection.html !== 'string') {
return accum
}
const location = htmlInjection.location ?? 'before_closing_head_tag'
if (location === 'before_closing_head_tag') {
accum = accum.replace('</head>', `${htmlInjection.html}</head>`)
} else if (location === 'before_closing_body_tag') {
accum = accum.replace('</body>', `${htmlInjection.html}</body>`)
}
return accum
}, decompressedBody.toString())
return await compressResponseBody(bodyWithInjections, proxyRes.headers['content-encoding'])
}

// @ts-expect-error TS(7006) FIXME: Parameter 'errorBuffer' implicitly has an 'any' ty... Remove this comment to see the full error message
const formatEdgeFunctionError = (errorBuffer, acceptsHtml) => {
const {
Expand Down Expand Up @@ -416,25 +455,16 @@ const reqToURL = function (req, pathname) {
const MILLISEC_TO_SEC = 1e3

const initializeProxy = async function ({
// @ts-expect-error TS(7031) FIXME: Binding element 'configPath' implicitly has an 'any... Remove this comment to see the full error message
config,
// @ts-expect-error TS(7031) FIXME: Binding element 'distDir' implicitly has an 'any... Remove this comment to see the full error message
configPath,
// @ts-expect-error TS(7031) FIXME: Binding element 'env' implicitly has an 'any... Remove this comment to see the full error message
distDir,
// @ts-expect-error TS(7031) FIXME: Binding element 'host' implicitly has an 'any... Remove this comment to see the full error message
env,
// @ts-expect-error TS(7031) FIXME: Binding element 'imageProxy' implicitly has an 'any... Remove this comment to see the full error message
host,
// @ts-expect-error TS(7031) FIXME: Binding element 'port' implicitly has an 'any... Remove this comment to see the full error message
imageProxy,
// @ts-expect-error TS(7031) FIXME: Binding element 'projectDir' implicitly has an 'any... Remove this comment to see the full error message
port,
// @ts-expect-error TS(7031) FIXME: Binding element 'siteInfo' implicitly has an 'any... Remove this comment to see the full error message
projectDir,
// @ts-expect-error TS(7031) FIXME: Binding element 'config' implicitly has an 'any... Remove this comment to see the full error message
siteInfo,
}) {
}: { config: NetlifyOptions['config'] } & Record<string, $TSFixMe>) {
const proxy = httpProxy.createProxyServer({
selfHandleResponse: true,
target: {
Expand Down Expand Up @@ -568,10 +598,18 @@ const initializeProxy = async function ({
const requestURL = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`)
const headersRules = headersForPath(headers, requestURL.pathname)

const htmlInjections =
config.dev?.processing?.html?.injections &&
config.dev.processing.html.injections.length !== 0 &&
proxyRes.headers?.['content-type']?.startsWith('text/html')
? config.dev.processing.html.injections
: undefined

// for streamed responses, we can't do etag generation nor error templates.
// we'll just stream them through!
// when html_injections are present in dev config, we can't use streamed response
const isStreamedResponse = proxyRes.headers['content-length'] === undefined
if (isStreamedResponse) {
if (isStreamedResponse && !htmlInjections) {
Object.entries(headersRules).forEach(([key, val]) => {
// @ts-expect-error TS(2345) FIXME: Argument of type 'unknown' is not assignable to pa... Remove this comment to see the full error message
res.setHeader(key, val)
Expand All @@ -596,7 +634,7 @@ const initializeProxy = async function ({

proxyRes.on('end', async function onEnd() {
// @ts-expect-error TS(7005) FIXME: Variable 'responseData' implicitly has an 'any[]' ... Remove this comment to see the full error message
const responseBody = Buffer.concat(responseData)
let responseBody = Buffer.concat(responseData)

// @ts-expect-error TS(2339) FIXME: Property 'proxyOptions' does not exist on type 'In... Remove this comment to see the full error message
let responseStatus = req.proxyOptions.status || proxyRes.statusCode
Expand Down Expand Up @@ -640,7 +678,17 @@ const initializeProxy = async function ({
return res.end()
}

res.writeHead(responseStatus, proxyRes.headers)
let proxyResHeaders = proxyRes.headers

if (htmlInjections) {
responseBody = await injectHtml(responseBody, proxyRes, htmlInjections)
proxyResHeaders = {
...proxyResHeaders,
'content-length': String(responseBody.byteLength),
}
}

res.writeHead(responseStatus, proxyResHeaders)

if (responseStatus !== 304) {
res.write(responseBody)
Expand Down
100 changes: 100 additions & 0 deletions tests/integration/commands/dev/responses.dev.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -297,4 +297,104 @@ describe.concurrent('commands/responses.dev', () => {
})
})
})

test('should inject html snippet from dev.processing.html.injections before closing head tag', async (t) => {
await withSiteBuilder(t, async (builder) => {
const pageHtml = '<html><head><title>title</title></head><body><h1>header</h1></body></html>'

builder
.withNetlifyToml({
config: {
plugins: [{ package: './plugins/injector' }],
},
})
.withBuildPlugin({
name: 'injector',
plugin: {
onPreDev: async ({ netlifyConfig }) => {
netlifyConfig.dev = {
...netlifyConfig.dev,
processing: {
...netlifyConfig.dev?.processing,
html: {
...netlifyConfig.dev?.processing?.html,
injections: [
...(netlifyConfig.dev?.processing?.html?.injections ?? []),
{
location: 'before_closing_head_tag',
html: '<script type="text/javascript" src="https://www.example.com"></script>',
},
],
},
},
}
},
},
})
.withContentFile({
path: 'index.html',
content: pageHtml,
})

await builder.build()

await withDevServer({ cwd: builder.directory }, async (server) => {
const response = await fetch(server.url)
const htmlResponse = await response.text()
t.expect(htmlResponse).toEqual(
pageHtml.replace('</head>', `<script type="text/javascript" src="https://www.example.com"></script></head>`),
)
})
})
})

test('should inject html snippet from dev.processing.html.injections before closing body tag', async (t) => {
await withSiteBuilder(t, async (builder) => {
const pageHtml = '<html><head><title>title</title></head><body><h1>header</h1></body></html>'

builder
.withNetlifyToml({
config: {
plugins: [{ package: './plugins/injector' }],
},
})
.withBuildPlugin({
name: 'injector',
plugin: {
onPreDev: async ({ netlifyConfig }) => {
netlifyConfig.dev = {
...netlifyConfig.dev,
processing: {
...netlifyConfig.dev?.processing,
html: {
...netlifyConfig.dev?.processing?.html,
injections: [
...(netlifyConfig.dev?.processing?.html?.injections ?? []),
{
location: 'before_closing_body_tag',
html: '<script type="text/javascript" src="https://www.example.com"></script>',
},
],
},
},
}
},
},
})
.withContentFile({
path: 'index.html',
content: pageHtml,
})

await builder.build()

await withDevServer({ cwd: builder.directory }, async (server) => {
const response = await fetch(server.url)
const htmlResponse = await response.text()
t.expect(htmlResponse).toEqual(
pageHtml.replace('</body>', `<script type="text/javascript" src="https://www.example.com"></script></body>`),
)
})
})
})
})

2 comments on commit c695931

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📊 Benchmark results

  • Dependency count: 1,228
  • Package size: 326 MB
  • Number of ts-expect-error directives: 978

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📊 Benchmark results

  • Dependency count: 1,228
  • Package size: 326 MB
  • Number of ts-expect-error directives: 978

Please sign in to comment.