Skip to content

Commit fbb2223

Browse files
committed
feat: add html rewriting
1 parent a2c88b7 commit fbb2223

File tree

5 files changed

+147
-32
lines changed

5 files changed

+147
-32
lines changed

demos/middleware/html_rewriter.d.ts

+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
export interface ContentTypeOptions {
2+
html?: boolean
3+
}
4+
5+
export class Element {
6+
before(content: string, options?: ContentTypeOptions): this
7+
after(content: string, options?: ContentTypeOptions): this
8+
replace(content: string, options?: ContentTypeOptions): this
9+
remove(): this
10+
getAttribute(name: string): string | null
11+
hasAttribute(name: string): boolean
12+
setAttribute(name: string, value: string): this
13+
removeAttribute(name: string): this
14+
prepend(content: string, options?: ContentTypeOptions): this
15+
append(content: string, options?: ContentTypeOptions): this
16+
setInnerContent(content: string, options?: ContentTypeOptions): this
17+
removeAndKeepContent(): this
18+
readonly attributes: IterableIterator<[string, string]>
19+
readonly namespaceURI: string
20+
readonly removed: boolean
21+
tagName: string
22+
onEndTag(handler: (this: this, endTag: EndTag) => void | Promise<void>): void
23+
}
24+
25+
export class EndTag {
26+
before(content: string, options?: ContentTypeOptions): this
27+
after(content: string, options?: ContentTypeOptions): this
28+
remove(): this
29+
name: string
30+
}
31+
32+
export class Comment {
33+
before(content: string, options?: ContentTypeOptions): this
34+
after(content: string, options?: ContentTypeOptions): this
35+
replace(content: string, options?: ContentTypeOptions): this
36+
remove(): this
37+
readonly removed: boolean
38+
text: string
39+
}
40+
41+
export class TextChunk {
42+
before(content: string, options?: ContentTypeOptions): this
43+
after(content: string, options?: ContentTypeOptions): this
44+
replace(content: string, options?: ContentTypeOptions): this
45+
remove(): this
46+
readonly lastInTextNode: boolean
47+
readonly removed: boolean
48+
readonly text: string
49+
}
50+
51+
export class Doctype {
52+
readonly name: string | null
53+
readonly publicId: string | null
54+
readonly systemId: string | null
55+
}
56+
57+
export class DocumentEnd {
58+
append(content: string, options?: ContentTypeOptions): this
59+
}
60+
61+
export interface ElementHandlers {
62+
element?(element: Element): void | Promise<void>
63+
comments?(comment: Comment): void | Promise<void>
64+
text?(text: TextChunk): void | Promise<void>
65+
}
66+
67+
export interface DocumentHandlers {
68+
doctype?(doctype: Doctype): void | Promise<void>
69+
comments?(comment: Comment): void | Promise<void>
70+
text?(text: TextChunk): void | Promise<void>
71+
end?(end: DocumentEnd): void | Promise<void>
72+
}
73+
74+
export interface HTMLRewriterOptions {
75+
enableEsiTags?: boolean
76+
}
77+
78+
export class HTMLRewriter {
79+
constructor(outputSink: (chunk: Uint8Array) => void, options?: HTMLRewriterOptions)
80+
on(selector: string, handlers: ElementHandlers): this
81+
onDocument(handlers: DocumentHandlers): this
82+
write(chunk: Uint8Array): Promise<void>
83+
end(): Promise<void>
84+
free(): void
85+
}

demos/middleware/middleware.ts

+25-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NextResponse } from 'next/server'
22
import type { NextRequest } from 'next/server'
3+
import type { ElementHandlers } from './html_rewriter'
34

45
/**
56
* Supercharge your Next middleware with Netlify Edge Functions
@@ -21,11 +22,18 @@ type NextDataTransform = <T extends Record<string, any>>(props: T) => T
2122
// We can't pass it through directly, because Next disallows returning a response body
2223
class NetlifyNextResponse extends NextResponse {
2324
private originResponse: Response
24-
private transforms: NextDataTransform[]
25+
private dataTransforms: NextDataTransform[]
26+
27+
private elementHandlers: Array<[selector: string, handlers: ElementHandlers]>
2528
constructor(originResponse: Response) {
2629
super()
2730
this.originResponse = originResponse
28-
Object.defineProperty(this, 'transforms', {
31+
Object.defineProperty(this, 'dataTransforms', {
32+
value: [],
33+
enumerable: false,
34+
writable: false,
35+
})
36+
Object.defineProperty(this, 'elementHandlers', {
2937
value: [],
3038
enumerable: false,
3139
writable: false,
@@ -38,8 +46,13 @@ class NetlifyNextResponse extends NextResponse {
3846
*/
3947
transformData(transform: NextDataTransform) {
4048
// The transforms are evaluated after the middleware is returned
41-
this.transforms.push(transform)
49+
this.dataTransforms.push(transform)
4250
}
51+
52+
rewriteHTML(selector: string, handlers: ElementHandlers) {
53+
this.elementHandlers.push([selector, handlers])
54+
}
55+
4356
get headers(): Headers {
4457
// If we have the origin response, we should use its headers
4558
return this.originResponse?.headers || super.headers
@@ -55,12 +68,20 @@ export async function middleware(request: NextRequest) {
5568
if (pathname.startsWith('/static')) {
5669
// Unlike NextResponse.next(), this actually sends the request to the origin
5770
const res = await NetlifyResponse.next(request)
71+
const message = `This was static but has been transformed in ${request.geo.city}`
72+
5873
res.transformData((data) => {
59-
data.pageProps.message = `This was static but has been transformed in ${request.geo.city}`
74+
data.pageProps.message = message
6075
data.pageProps.showAd = true
6176
return data
6277
})
6378

79+
res.rewriteHTML('p[id=message]', {
80+
text(textChunk) {
81+
textChunk.replace(message)
82+
},
83+
})
84+
6485
return res
6586
}
6687

demos/middleware/pages/static.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const Page = ({ message, showAd }) => (
22
<div>
3-
<p>{message}</p>
3+
<p id="message">{message}</p>
44
{showAd ? (
55
<div>
66
<p>This is an ad that isn't shown by default</p>

plugin/src/templates/edge/bundle.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* This placeholder is replaced with the compiled Next.js bundle at build time
3-
* @args {Object}
4-
* @args.request {import("./runtime.ts").RequestData}
3+
* @param {Object} props
4+
* @param {import("./runtime.ts").RequestData} props.request
55
* @returns {Promise<import("./utils.ts").FetchEventResult>}
66
*/
7-
export default async (props) => {}
7+
export default async ({ request }) => {}

plugin/src/templates/edge/utils.ts

+33-24
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Context } from 'netlify:edge'
2-
import { HTMLRewriter } from 'https://deno.land/x/[email protected]/index.ts'
2+
import { ElementHandlers, HTMLRewriter } from 'https://deno.land/x/[email protected]/index.ts'
33

44
export interface FetchEventResult {
55
response: Response
@@ -39,7 +39,8 @@ export const addMiddlewareHeaders = async (
3939

4040
interface NetlifyNextResponse extends Response {
4141
originResponse: Response
42-
transforms: NextDataTransform[]
42+
dataTransforms: NextDataTransform[]
43+
elementHandlers: Array<[selector: string, handlers: ElementHandlers]>
4344
}
4445

4546
export const buildResponse = async ({
@@ -52,40 +53,48 @@ export const buildResponse = async ({
5253
context: Context
5354
}) => {
5455
// This means it's a Netlify Next response.
55-
if ('transforms' in result.response) {
56+
if ('dataTransforms' in result.response) {
5657
const response = result.response as NetlifyNextResponse
5758
// If it's JSON we don't need to use the rewriter, we can just parse it
5859
if (response.originResponse.headers.get('content-type')?.includes('application/json')) {
5960
const props = await response.originResponse.json()
60-
const transformed = response.transforms.reduce((prev, transform) => {
61+
const transformed = response.dataTransforms.reduce((prev, transform) => {
6162
return transform(prev)
6263
}, props)
6364
return context.json(transformed)
6465
}
6566
// This var will hold the contents of the script tag
6667
let buffer = ''
6768
// Create an HTMLRewriter that matches the Next data script tag
68-
const rewriter = new HTMLRewriter().on('script[id="__NEXT_DATA__"]', {
69-
text(textChunk) {
70-
// Grab all the chunks in the Next data script tag
71-
buffer += textChunk.text
72-
if (textChunk.lastInTextNode) {
73-
try {
74-
// When we have all the data, try to parse it as JSON
75-
const data = JSON.parse(buffer.trim())
76-
// Apply all of the transforms to the props
77-
const props = response.transforms.reduce((prev, transform) => transform(prev), data.props)
78-
// Replace the data with the transformed props
79-
textChunk.replace(JSON.stringify({ ...data, props }))
80-
} catch (err) {
81-
console.log('Could not parse', err)
69+
const rewriter = new HTMLRewriter()
70+
71+
if (response.dataTransforms.length > 0) {
72+
rewriter.on('script[id="__NEXT_DATA__"]', {
73+
text(textChunk) {
74+
// Grab all the chunks in the Next data script tag
75+
buffer += textChunk.text
76+
if (textChunk.lastInTextNode) {
77+
try {
78+
// When we have all the data, try to parse it as JSON
79+
const data = JSON.parse(buffer.trim())
80+
// Apply all of the transforms to the props
81+
const props = response.dataTransforms.reduce((prev, transform) => transform(prev), data.props)
82+
// Replace the data with the transformed props
83+
textChunk.replace(JSON.stringify({ ...data, props }))
84+
} catch (err) {
85+
console.log('Could not parse', err)
86+
}
87+
} else {
88+
// Remove the chunk after we've appended it to the buffer
89+
textChunk.remove()
8290
}
83-
} else {
84-
// Remove the chunk after we've appended it to the buffer
85-
textChunk.remove()
86-
}
87-
},
88-
})
91+
},
92+
})
93+
}
94+
95+
if (response.elementHandlers.length > 0) {
96+
response.elementHandlers.forEach(([selector, handlers]) => rewriter.on(selector, handlers))
97+
}
8998
return rewriter.transform(response.originResponse)
9099
}
91100
const res = new Response(result.response.body, result.response)

0 commit comments

Comments
 (0)