Skip to content

Commit caf9681

Browse files
committed
Fix preview proxy editor save requests
1 parent 6445ee1 commit caf9681

3 files changed

Lines changed: 139 additions & 20 deletions

File tree

packages/runtime-playground/src/preview-server.ts

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { createServer as createHttpServer, request as httpRequest, type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from "node:http"
22
import { createServer as createNetServer } from "node:net"
3+
import { StringDecoder } from "node:string_decoder"
4+
import { Transform } from "node:stream"
35
import type { PreviewLease } from "@automattic/wp-codebox-core"
46

57
export interface PlaygroundServerRunResponse {
@@ -81,13 +83,19 @@ export async function withPreviewProxy(server: PlaygroundCliServer, port: number
8183
async function startPreviewProxy(targetUrl: string, port: number, bind: string): Promise<PlaygroundPreviewProxy> {
8284
const target = new URL(targetUrl)
8385
const routes = createPreviewRouteRegistry()
84-
const proxy = previewProxyServer(target, routes)
86+
let proxyOrigin: string | undefined
87+
const proxy = previewProxyServer(target, routes, () => proxyOrigin)
8588
const servers = [proxy]
8689

8790
await listenPreviewProxy(proxy, port, bind)
8891

92+
const address = proxy.address()
93+
const resolvedPort = address && typeof address === "object" ? address.port : port
94+
const reportedHost = bind === "0.0.0.0" ? "127.0.0.1" : bind
95+
proxyOrigin = `http://${formatPreviewHost(reportedHost)}:${resolvedPort}`
96+
8997
if (bind === "127.0.0.1") {
90-
const ipv6Proxy = previewProxyServer(target, routes)
98+
const ipv6Proxy = previewProxyServer(target, routes, () => `http://[::1]:${resolvedPort}`)
9199
try {
92100
await listenPreviewProxy(ipv6Proxy, port, "::1")
93101
servers.push(ipv6Proxy)
@@ -99,12 +107,8 @@ async function startPreviewProxy(targetUrl: string, port: number, bind: string):
99107
}
100108
}
101109

102-
const address = proxy.address()
103-
const resolvedPort = address && typeof address === "object" ? address.port : port
104-
const reportedHost = bind === "0.0.0.0" ? "127.0.0.1" : bind
105-
106110
return {
107-
serverUrl: `http://${formatPreviewHost(reportedHost)}:${resolvedPort}`,
111+
serverUrl: proxyOrigin,
108112
previewRoutes: routes,
109113
diagnostics: {
110114
schema: "wp-codebox/preview-proxy-diagnostics/v1",
@@ -120,7 +124,7 @@ async function startPreviewProxy(targetUrl: string, port: number, bind: string):
120124
}
121125
}
122126

123-
function previewProxyServer(target: URL, routes: InternalPreviewRouteRegistry): PreviewProxyServer {
127+
function previewProxyServer(target: URL, routes: InternalPreviewRouteRegistry, proxyOrigin: () => string | undefined): PreviewProxyServer {
124128
const upstreamQueue = createPreviewProxyQueue()
125129

126130
return createHttpServer(async (incoming, outgoing) => {
@@ -133,7 +137,7 @@ function previewProxyServer(target: URL, routes: InternalPreviewRouteRegistry):
133137
return
134138
}
135139

136-
upstreamQueue(() => proxyPreviewRequest(target, incoming, outgoing)).catch((error: Error) => writeProxyError(outgoing, error))
140+
upstreamQueue(() => proxyPreviewRequest(target, proxyOrigin(), incoming, outgoing)).catch((error: Error) => writeProxyError(outgoing, error))
137141
})
138142
}
139143

@@ -159,7 +163,7 @@ function createPreviewRouteRegistry(): InternalPreviewRouteRegistry {
159163
}
160164
}
161165

162-
function proxyPreviewRequest(target: URL, incoming: IncomingMessage, outgoing: ServerResponse): Promise<void> {
166+
function proxyPreviewRequest(target: URL, proxyOrigin: string | undefined, incoming: IncomingMessage, outgoing: ServerResponse): Promise<void> {
163167
return new Promise((resolve) => {
164168
let settled = false
165169
let targetResponse: IncomingMessage | undefined
@@ -187,7 +191,27 @@ function proxyPreviewRequest(target: URL, incoming: IncomingMessage, outgoing: S
187191
},
188192
(response) => {
189193
targetResponse = response
190-
outgoing.writeHead(response.statusCode ?? 502, response.statusMessage, proxyResponseHeaders(response.headers))
194+
if (proxyOrigin && shouldRewriteProxyResponse(response.headers)) {
195+
const headers = proxyResponseHeaders(response.headers, target.origin, proxyOrigin)
196+
delete headers["content-length"]
197+
delete headers.etag
198+
outgoing.writeHead(response.statusCode ?? 502, response.statusMessage, headers)
199+
const rewrite = previewOriginRewriteStream(target.origin, proxyOrigin)
200+
response.on("error", (error) => {
201+
outgoing.destroy(error)
202+
settle()
203+
})
204+
rewrite.on("error", (error) => {
205+
outgoing.destroy(error)
206+
settle()
207+
})
208+
outgoing.on("finish", settle)
209+
outgoing.on("close", settle)
210+
response.pipe(rewrite).pipe(outgoing)
211+
return
212+
}
213+
214+
outgoing.writeHead(response.statusCode ?? 502, response.statusMessage, proxyResponseHeaders(response.headers, target.origin, proxyOrigin))
191215
response.on("error", (error) => {
192216
outgoing.destroy(error)
193217
settle()
@@ -211,6 +235,43 @@ function proxyPreviewRequest(target: URL, incoming: IncomingMessage, outgoing: S
211235
})
212236
}
213237

238+
function shouldRewriteProxyResponse(headers: IncomingHttpHeaders): boolean {
239+
const contentType = String(headers["content-type"] ?? "").toLowerCase()
240+
const contentEncoding = String(headers["content-encoding"] ?? "identity").toLowerCase()
241+
return (contentEncoding === "" || contentEncoding === "identity")
242+
&& (contentType.startsWith("text/") || /(?:^|\/)(?:[^;]+\+)?(?:json|javascript|xml)(?:;|$)/.test(contentType))
243+
}
244+
245+
function rewritePreviewOrigin(body: string, targetOrigin: string, proxyOrigin: string): string {
246+
return body
247+
.replaceAll(targetOrigin, proxyOrigin)
248+
.replaceAll(targetOrigin.replaceAll("/", "\\/"), proxyOrigin.replaceAll("/", "\\/"))
249+
}
250+
251+
function previewOriginRewriteStream(targetOrigin: string, proxyOrigin: string): Transform {
252+
const decoder = new StringDecoder("utf8")
253+
const patterns = [targetOrigin, targetOrigin.replaceAll("/", "\\/")]
254+
const retainedCharacters = Math.max(...patterns.map((pattern) => pattern.length)) - 1
255+
let pending = ""
256+
257+
return new Transform({
258+
transform(chunk: Buffer, _encoding, callback) {
259+
const text = pending + decoder.write(chunk)
260+
let splitAt = Math.max(0, text.length - retainedCharacters)
261+
for (const pattern of patterns) {
262+
for (let index = Math.max(0, splitAt - pattern.length + 1); index < splitAt; index += 1) {
263+
if (pattern.startsWith(text.slice(index, splitAt))) splitAt = index
264+
}
265+
}
266+
pending = text.slice(splitAt)
267+
callback(null, rewritePreviewOrigin(text.slice(0, splitAt), targetOrigin, proxyOrigin))
268+
},
269+
flush(callback) {
270+
callback(null, rewritePreviewOrigin(pending + decoder.end(), targetOrigin, proxyOrigin))
271+
},
272+
})
273+
}
274+
214275
function createPreviewProxyQueue(): (task: () => Promise<void>) => Promise<void> {
215276
let active = false
216277
const pending: Array<() => void> = []
@@ -277,11 +338,18 @@ function proxyRequestHeaders(headers: IncomingHttpHeaders): IncomingHttpHeaders
277338
}
278339
}
279340

280-
function proxyResponseHeaders(headers: IncomingHttpHeaders): IncomingHttpHeaders {
341+
function proxyResponseHeaders(headers: IncomingHttpHeaders, targetOrigin?: string, proxyOrigin?: string): IncomingHttpHeaders {
281342
const forwarded = { ...headers }
282343
delete forwarded.connection
283344
delete forwarded["transfer-encoding"]
284345

346+
if (targetOrigin && proxyOrigin) {
347+
for (const [name, value] of Object.entries(forwarded)) {
348+
if (typeof value === "string") forwarded[name] = rewritePreviewOrigin(value, targetOrigin, proxyOrigin)
349+
else if (Array.isArray(value)) forwarded[name] = value.map((item) => rewritePreviewOrigin(item, targetOrigin, proxyOrigin))
350+
}
351+
}
352+
285353
return forwarded
286354
}
287355

tests/browser-callback-materialization-contracts.test.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from "node:assert/strict"
2-
import { createServer } from "node:http"
2+
import { createServer, type IncomingHttpHeaders } from "node:http"
33
import {
44
artifactBundleFileManifest,
55
browserRunResultEnvelope,
@@ -138,14 +138,39 @@ assert.deepEqual(browserPreviewAuthCookieUrls("http://localhost:9400", ["PUBLIC.
138138

139139
let activeUpstreamRequests = 0
140140
let maxActiveUpstreamRequests = 0
141-
const targetServer = createServer(async (_request, response) => {
141+
let targetServerUrl = ""
142+
const restRequests: Array<{ method?: string; headers: IncomingHttpHeaders; body: string }> = []
143+
const targetServer = createServer(async (request, response) => {
142144
activeUpstreamRequests += 1
143145
maxActiveUpstreamRequests = Math.max(maxActiveUpstreamRequests, activeUpstreamRequests)
144146
await new Promise((resolveDelay) => setTimeout(resolveDelay, 25))
145147
activeUpstreamRequests -= 1
148+
149+
if (request.url === "/editor") {
150+
response.setHeader("content-type", "text/html; charset=utf-8")
151+
const escapedOrigin = targetServerUrl.replaceAll("/", "\\/")
152+
const splitAt = Math.floor(escapedOrigin.length / 2)
153+
response.write(`<script>window.wpApiSettings={"root":"${escapedOrigin.slice(0, splitAt)}`)
154+
response.end(`${escapedOrigin.slice(splitAt)}\\/wp-json\\/"}</script>`)
155+
return
156+
}
157+
if (request.url === "/wp-json/wp/v2/pages/5") {
158+
let body = ""
159+
for await (const chunk of request) body += chunk.toString()
160+
restRequests.push({ method: request.method, headers: request.headers, body })
161+
if (request.method === "OPTIONS") {
162+
response.writeHead(204, { "access-control-allow-headers": "content-type, x-wp-nonce" })
163+
response.end()
164+
return
165+
}
166+
response.setHeader("content-type", "application/json")
167+
response.end(JSON.stringify({ updated: true }))
168+
return
169+
}
170+
146171
response.end("ok")
147172
})
148-
const targetServerUrl = await listenLocalHttpServer(targetServer)
173+
targetServerUrl = await listenLocalHttpServer(targetServer)
149174
let disposed = false
150175
const proxied = await withPreviewProxy({
151176
playground: { async run() { return { text: "" } } },
@@ -165,6 +190,31 @@ try {
165190
})
166191
await Promise.all([fetch(proxied.serverUrl), fetch(proxied.serverUrl)])
167192
assert.equal(maxActiveUpstreamRequests, 1)
193+
194+
const editorHtml = await fetch(`${proxied.serverUrl}/editor`).then((response) => response.text())
195+
const restUrl = editorHtml.match(/root":"([^\"]+)/)?.[1]?.replaceAll("\\/", "/")
196+
assert.equal(restUrl, `${proxied.serverUrl}/wp-json/`, "WordPress browser URLs use the preview proxy origin")
197+
198+
const mutationUrl = new URL("wp/v2/pages/5", restUrl).toString()
199+
const preflight = await fetch(mutationUrl, {
200+
method: "OPTIONS",
201+
headers: {
202+
origin: proxied.serverUrl,
203+
"access-control-request-method": "POST",
204+
"access-control-request-headers": "content-type,x-http-method-override,x-wp-nonce",
205+
},
206+
})
207+
assert.equal(preflight.status, 204)
208+
assert.equal(restRequests[0]?.headers["access-control-request-headers"], "content-type,x-http-method-override,x-wp-nonce")
209+
210+
const mutation = await fetch(mutationUrl, {
211+
method: "POST",
212+
headers: { "content-type": "application/json", "x-http-method-override": "PUT", "x-wp-nonce": "nonce" },
213+
body: JSON.stringify({ content: "updated" }),
214+
}).then((response) => response.json())
215+
assert.deepEqual(mutation, { updated: true })
216+
assert.equal(restRequests[1]?.headers["x-http-method-override"], "PUT")
217+
assert.equal(restRequests[1]?.body, JSON.stringify({ content: "updated" }))
168218
} finally {
169219
await proxied[Symbol.asyncDispose]()
170220
await closeHttpServer(targetServer)

tests/editor-actions.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ assert.equal(nestedValidity.warnings[0]?.clientId, "nested-invalid")
281281
const frontPageTarget = editorOpenTargetFromArgs(["target=front-page"])
282282
assert.equal(frontPageTarget.kind, "front-page")
283283
assert.equal(frontPageTarget.url, "")
284+
const editorRuntimeSpec = { wp: "latest", environment: {} } as never
284285

285286
// resolveEditorOpenTarget asks the running WordPress for page_on_front and rewrites
286287
// the target to open that exact post in the editor.
@@ -291,7 +292,7 @@ const resolved = await resolveEditorOpenTarget(frontPageTarget, {
291292
resolveCalls.push(command)
292293
return { ok: true, text: "57\n" } as never
293294
},
294-
runtimeSpec: { wp: "latest" } as never,
295+
runtimeSpec: editorRuntimeSpec,
295296
server: { serverUrl: "http://localhost" } as never,
296297
})
297298
assert.equal(resolved.kind, "post")
@@ -314,7 +315,7 @@ const resolvedPostSlug = await resolveEditorOpenTarget(postSlugTarget, {
314315
postSlugCalls.push(command)
315316
return { ok: true, text: "91\n" } as never
316317
},
317-
runtimeSpec: { wp: "latest" } as never,
318+
runtimeSpec: editorRuntimeSpec,
318319
server: { serverUrl: "http://localhost" } as never,
319320
})
320321
assert.equal(resolvedPostSlug.kind, "post")
@@ -326,7 +327,7 @@ await assert.rejects(
326327
resolveEditorOpenTarget(postSlugTarget, {
327328
command: "wordpress.editor-open",
328329
runPlaygroundCommand: async () => ({ ok: true, text: "0" } as never),
329-
runtimeSpec: { wp: "latest" } as never,
330+
runtimeSpec: editorRuntimeSpec,
330331
server: { serverUrl: "http://localhost" } as never,
331332
}),
332333
/resolved no editable post/,
@@ -338,7 +339,7 @@ await assert.rejects(
338339
resolveEditorOpenTarget(frontPageTarget, {
339340
command: "wordpress.editor-validate-blocks",
340341
runPlaygroundCommand: async () => ({ ok: true, text: "0" } as never),
341-
runtimeSpec: { wp: "latest" } as never,
342+
runtimeSpec: editorRuntimeSpec,
342343
server: { serverUrl: "http://localhost" } as never,
343344
}),
344345
/no static front page/,
@@ -351,7 +352,7 @@ const passthrough = await resolveEditorOpenTarget(postTarget, {
351352
runPlaygroundCommand: async () => {
352353
throw new Error("resolveEditorOpenTarget should not run PHP for a concrete target")
353354
},
354-
runtimeSpec: { wp: "latest" } as never,
355+
runtimeSpec: editorRuntimeSpec,
355356
server: { serverUrl: "http://localhost" } as never,
356357
})
357358
assert.equal(passthrough.url, "/wp-admin/post.php?post=12&action=edit")

0 commit comments

Comments
 (0)