Skip to content

Commit c695931

Browse files
authored
fix: adds html injections to dev proxy [CRE-1203] (#6686)
feat: [CRE-1203] added html injections to dev proxy
1 parent aba9592 commit c695931

File tree

7 files changed

+488
-631
lines changed

7 files changed

+488
-631
lines changed

package-lock.json

+297-608
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+4-4
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,13 @@
7373
"@bugsnag/js": "7.23.0",
7474
"@fastify/static": "7.0.4",
7575
"@netlify/blobs": "7.3.0",
76-
"@netlify/build": "29.46.2",
76+
"@netlify/build": "29.46.4",
7777
"@netlify/build-info": "7.13.2",
78-
"@netlify/config": "20.13.2",
78+
"@netlify/config": "20.14.1",
7979
"@netlify/edge-bundler": "12.0.1",
8080
"@netlify/edge-functions": "2.8.1",
8181
"@netlify/local-functions-proxy": "1.1.1",
82-
"@netlify/zip-it-and-ship-it": "9.34.0",
82+
"@netlify/zip-it-and-ship-it": "9.34.1",
8383
"@octokit/rest": "20.1.1",
8484
"@opentelemetry/api": "1.8.0",
8585
"ansi-escapes": "7.0.0",
@@ -187,7 +187,7 @@
187187
},
188188
"devDependencies": {
189189
"@babel/preset-react": "7.24.6",
190-
"@netlify/eslint-config-node": "7.0.0",
190+
"@netlify/eslint-config-node": "7.0.1",
191191
"@netlify/functions": "2.7.0",
192192
"@sindresorhus/slugify": "2.2.1",
193193
"@types/fs-extra": "11.0.4",

src/commands/base-command.ts

-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { format } from 'util'
77

88
import { DefaultLogger, Project } from '@netlify/build-info'
99
import { NodeFS, NoopLogger } from '@netlify/build-info/node'
10-
// @ts-expect-error TS(7016) FIXME: Could not find a declaration file for module '@net... Remove this comment to see the full error message
1110
import { resolveConfig } from '@netlify/config'
1211
import { Command, Help, Option } from 'commander'
1312
// @ts-expect-error TS(7016) FIXME: Could not find a declaration file for module 'debu... Remove this comment to see the full error message

src/commands/dev/dev.ts

-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import process from 'process'
22

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

src/commands/types.d.ts

+24-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { NetlifyTOML } from '@netlify/build-info'
1+
import type { NetlifyConfig } from "@netlify/build";
2+
import type { NetlifyTOML } from '@netlify/build-info'
23
import type { NetlifyAPI } from 'netlify'
34

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

17-
type PatchedConfig = NetlifyTOML & {
18+
type PatchedConfig = NetlifyTOML & Pick<NetlifyConfig, 'images'> & {
1819
functionsDirectory?: string
1920
build: NetlifyTOML['build'] & {
2021
functionsSource?: string
2122
}
2223
dev: NetlifyTOML['dev'] & {
2324
functions?: string
25+
processing?: DevProcessing
2426
}
2527
}
2628

29+
type DevProcessing = {
30+
html?: HTMLProcessing
31+
}
32+
33+
type HTMLProcessing = {
34+
injections?: HTMLInjection[]
35+
}
36+
37+
type HTMLInjection = {
38+
/**
39+
* The location at which the `html` will be injected.
40+
* Defaults to `before_closing_head_tag` which will inject the HTML before the </head> tag.
41+
*/
42+
location?: 'before_closing_head_tag' | 'before_closing_body_tag',
43+
/**
44+
* The injected HTML code.
45+
*/
46+
html: string
47+
}
48+
2749
type EnvironmentVariableScope = 'builds' | 'functions' | 'runtime' | 'post_processing'
2850
type EnvironmentVariableSource = 'account' | 'addons' | 'configFile' | 'general' | 'internal' | 'ui'
2951

src/utils/proxy.ts

+63-15
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import pFilter from 'p-filter'
2626
import toReadableStream from 'to-readable-stream'
2727

2828
import { BaseCommand } from '../commands/index.js'
29-
import { $TSFixMe } from '../commands/types.js'
29+
import { $TSFixMe, NetlifyOptions } from '../commands/types.js'
3030
import {
3131
handleProxyRequest,
3232
initializeProxy as initializeEdgeFunctionsProxy,
@@ -47,8 +47,11 @@ import { signRedirect } from './sign-redirect.js'
4747
import { Request, Rewriter, ServerSettings } from './types.js'
4848

4949
const gunzip = util.promisify(zlib.gunzip)
50+
const gzip = util.promisify(zlib.gzip)
5051
const brotliDecompress = util.promisify(zlib.brotliDecompress)
52+
const brotliCompress = util.promisify(zlib.brotliCompress)
5153
const deflate = util.promisify(zlib.deflate)
54+
const inflate = util.promisify(zlib.inflate)
5255
const shouldGenerateETag = Symbol('Internal: response should generate ETag')
5356

5457
const decompressResponseBody = async function (body: Buffer, contentEncoding = ''): Promise<Buffer> {
@@ -58,12 +61,48 @@ const decompressResponseBody = async function (body: Buffer, contentEncoding = '
5861
case 'br':
5962
return await brotliDecompress(body)
6063
case 'deflate':
61-
return await deflate(body)
64+
return await inflate(body)
6265
default:
6366
return body
6467
}
6568
}
6669

70+
const compressResponseBody = async function (body: string, contentEncoding = ''): Promise<Buffer> {
71+
switch (contentEncoding) {
72+
case 'gzip':
73+
return await gzip(body)
74+
case 'br':
75+
return await brotliCompress(body)
76+
case 'deflate':
77+
return await deflate(body)
78+
default:
79+
return Buffer.from(body, 'utf8')
80+
}
81+
}
82+
83+
type HTMLInjections = NonNullable<NonNullable<NetlifyOptions['config']['dev']['processing']>['html']>['injections']
84+
85+
const injectHtml = async function (
86+
responseBody: Buffer,
87+
proxyRes: http.IncomingMessage,
88+
htmlInjections: HTMLInjections,
89+
): Promise<Buffer> {
90+
const decompressedBody: Buffer = await decompressResponseBody(responseBody, proxyRes.headers['content-encoding'])
91+
const bodyWithInjections: string = (htmlInjections ?? []).reduce((accum, htmlInjection) => {
92+
if (!htmlInjection.html || typeof htmlInjection.html !== 'string') {
93+
return accum
94+
}
95+
const location = htmlInjection.location ?? 'before_closing_head_tag'
96+
if (location === 'before_closing_head_tag') {
97+
accum = accum.replace('</head>', `${htmlInjection.html}</head>`)
98+
} else if (location === 'before_closing_body_tag') {
99+
accum = accum.replace('</body>', `${htmlInjection.html}</body>`)
100+
}
101+
return accum
102+
}, decompressedBody.toString())
103+
return await compressResponseBody(bodyWithInjections, proxyRes.headers['content-encoding'])
104+
}
105+
67106
// @ts-expect-error TS(7006) FIXME: Parameter 'errorBuffer' implicitly has an 'any' ty... Remove this comment to see the full error message
68107
const formatEdgeFunctionError = (errorBuffer, acceptsHtml) => {
69108
const {
@@ -416,25 +455,16 @@ const reqToURL = function (req, pathname) {
416455
const MILLISEC_TO_SEC = 1e3
417456

418457
const initializeProxy = async function ({
419-
// @ts-expect-error TS(7031) FIXME: Binding element 'configPath' implicitly has an 'any... Remove this comment to see the full error message
420458
config,
421-
// @ts-expect-error TS(7031) FIXME: Binding element 'distDir' implicitly has an 'any... Remove this comment to see the full error message
422459
configPath,
423-
// @ts-expect-error TS(7031) FIXME: Binding element 'env' implicitly has an 'any... Remove this comment to see the full error message
424460
distDir,
425-
// @ts-expect-error TS(7031) FIXME: Binding element 'host' implicitly has an 'any... Remove this comment to see the full error message
426461
env,
427-
// @ts-expect-error TS(7031) FIXME: Binding element 'imageProxy' implicitly has an 'any... Remove this comment to see the full error message
428462
host,
429-
// @ts-expect-error TS(7031) FIXME: Binding element 'port' implicitly has an 'any... Remove this comment to see the full error message
430463
imageProxy,
431-
// @ts-expect-error TS(7031) FIXME: Binding element 'projectDir' implicitly has an 'any... Remove this comment to see the full error message
432464
port,
433-
// @ts-expect-error TS(7031) FIXME: Binding element 'siteInfo' implicitly has an 'any... Remove this comment to see the full error message
434465
projectDir,
435-
// @ts-expect-error TS(7031) FIXME: Binding element 'config' implicitly has an 'any... Remove this comment to see the full error message
436466
siteInfo,
437-
}) {
467+
}: { config: NetlifyOptions['config'] } & Record<string, $TSFixMe>) {
438468
const proxy = httpProxy.createProxyServer({
439469
selfHandleResponse: true,
440470
target: {
@@ -568,10 +598,18 @@ const initializeProxy = async function ({
568598
const requestURL = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`)
569599
const headersRules = headersForPath(headers, requestURL.pathname)
570600

601+
const htmlInjections =
602+
config.dev?.processing?.html?.injections &&
603+
config.dev.processing.html.injections.length !== 0 &&
604+
proxyRes.headers?.['content-type']?.startsWith('text/html')
605+
? config.dev.processing.html.injections
606+
: undefined
607+
571608
// for streamed responses, we can't do etag generation nor error templates.
572609
// we'll just stream them through!
610+
// when html_injections are present in dev config, we can't use streamed response
573611
const isStreamedResponse = proxyRes.headers['content-length'] === undefined
574-
if (isStreamedResponse) {
612+
if (isStreamedResponse && !htmlInjections) {
575613
Object.entries(headersRules).forEach(([key, val]) => {
576614
// @ts-expect-error TS(2345) FIXME: Argument of type 'unknown' is not assignable to pa... Remove this comment to see the full error message
577615
res.setHeader(key, val)
@@ -596,7 +634,7 @@ const initializeProxy = async function ({
596634

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

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

643-
res.writeHead(responseStatus, proxyRes.headers)
681+
let proxyResHeaders = proxyRes.headers
682+
683+
if (htmlInjections) {
684+
responseBody = await injectHtml(responseBody, proxyRes, htmlInjections)
685+
proxyResHeaders = {
686+
...proxyResHeaders,
687+
'content-length': String(responseBody.byteLength),
688+
}
689+
}
690+
691+
res.writeHead(responseStatus, proxyResHeaders)
644692

645693
if (responseStatus !== 304) {
646694
res.write(responseBody)

tests/integration/commands/dev/responses.dev.test.js

+100
Original file line numberDiff line numberDiff line change
@@ -297,4 +297,104 @@ describe.concurrent('commands/responses.dev', () => {
297297
})
298298
})
299299
})
300+
301+
test('should inject html snippet from dev.processing.html.injections before closing head tag', async (t) => {
302+
await withSiteBuilder(t, async (builder) => {
303+
const pageHtml = '<html><head><title>title</title></head><body><h1>header</h1></body></html>'
304+
305+
builder
306+
.withNetlifyToml({
307+
config: {
308+
plugins: [{ package: './plugins/injector' }],
309+
},
310+
})
311+
.withBuildPlugin({
312+
name: 'injector',
313+
plugin: {
314+
onPreDev: async ({ netlifyConfig }) => {
315+
netlifyConfig.dev = {
316+
...netlifyConfig.dev,
317+
processing: {
318+
...netlifyConfig.dev?.processing,
319+
html: {
320+
...netlifyConfig.dev?.processing?.html,
321+
injections: [
322+
...(netlifyConfig.dev?.processing?.html?.injections ?? []),
323+
{
324+
location: 'before_closing_head_tag',
325+
html: '<script type="text/javascript" src="https://www.example.com"></script>',
326+
},
327+
],
328+
},
329+
},
330+
}
331+
},
332+
},
333+
})
334+
.withContentFile({
335+
path: 'index.html',
336+
content: pageHtml,
337+
})
338+
339+
await builder.build()
340+
341+
await withDevServer({ cwd: builder.directory }, async (server) => {
342+
const response = await fetch(server.url)
343+
const htmlResponse = await response.text()
344+
t.expect(htmlResponse).toEqual(
345+
pageHtml.replace('</head>', `<script type="text/javascript" src="https://www.example.com"></script></head>`),
346+
)
347+
})
348+
})
349+
})
350+
351+
test('should inject html snippet from dev.processing.html.injections before closing body tag', async (t) => {
352+
await withSiteBuilder(t, async (builder) => {
353+
const pageHtml = '<html><head><title>title</title></head><body><h1>header</h1></body></html>'
354+
355+
builder
356+
.withNetlifyToml({
357+
config: {
358+
plugins: [{ package: './plugins/injector' }],
359+
},
360+
})
361+
.withBuildPlugin({
362+
name: 'injector',
363+
plugin: {
364+
onPreDev: async ({ netlifyConfig }) => {
365+
netlifyConfig.dev = {
366+
...netlifyConfig.dev,
367+
processing: {
368+
...netlifyConfig.dev?.processing,
369+
html: {
370+
...netlifyConfig.dev?.processing?.html,
371+
injections: [
372+
...(netlifyConfig.dev?.processing?.html?.injections ?? []),
373+
{
374+
location: 'before_closing_body_tag',
375+
html: '<script type="text/javascript" src="https://www.example.com"></script>',
376+
},
377+
],
378+
},
379+
},
380+
}
381+
},
382+
},
383+
})
384+
.withContentFile({
385+
path: 'index.html',
386+
content: pageHtml,
387+
})
388+
389+
await builder.build()
390+
391+
await withDevServer({ cwd: builder.directory }, async (server) => {
392+
const response = await fetch(server.url)
393+
const htmlResponse = await response.text()
394+
t.expect(htmlResponse).toEqual(
395+
pageHtml.replace('</body>', `<script type="text/javascript" src="https://www.example.com"></script></body>`),
396+
)
397+
})
398+
})
399+
})
300400
})

0 commit comments

Comments
 (0)