Skip to content

Commit 13714ad

Browse files
Copilotadamziel
andauthored
[Website] Run HTTPS test server for tcp-over-fetch-websocket tests (#2903)
## Motivation for the change, related issues `fetchWithCorsProxy` now upgrades HTTP URLs to HTTPS. Tests making requests to `http://127.0.0.1:PORT` get upgraded to `https://127.0.0.1:PORT`, requiring an HTTPS test server. ## Implementation details **Test server migration to HTTPS** - Generate self-signed certificates with SANs for `127.0.0.1` using `selfsigned` library - Convert Express server from `app.listen()` to `https.createServer()` - Set `NODE_TLS_REJECT_UNAUTHORIZED=0` for test environment **Fix request body stream handling** - `cloneRequest()` was calling `await request.blob()` to clone requests with bodies - This hung on POST requests because the body stream was still awaiting data that would never arrive - Solution: Reuse unconsumed body streams directly via `request.body` instead of reading to Blob ```typescript // Before: Hangs when body stream is still open const body = await request.blob(); // After: Reuses stream when possible const body = !request.bodyUsed && request.body ? request.body : await request.blob(); ``` - Added `duplex: 'half'` to Request creation for Node.js streaming body compatibility - Removed unused `@ts-expect-error` directive as TypeScript now properly recognizes the `duplex` property ## Testing Instructions (or ideally a Blueprint) CI. All 31 tests in `tcp-over-fetch-websocket.spec.ts` pass, including POST requests with bodies. <!-- START COPILOT CODING AGENT TIPS --> --- ✨ Let Copilot coding agent [set things up for you](https://github.com/WordPress/wordpress-playground/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: adamziel <205419+adamziel@users.noreply.github.com> Co-authored-by: Adam Zieliński <adam@adamziel.com>
1 parent a6d1646 commit 13714ad

File tree

4 files changed

+123
-22
lines changed

4 files changed

+123
-22
lines changed

packages/php-wasm/web-service-worker/src/utils.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,19 @@ export async function cloneRequest(
166166
request: Request,
167167
overrides: Record<string, any>
168168
): Promise<Request> {
169-
const body =
170-
['GET', 'HEAD'].includes(request.method) || 'body' in overrides
171-
? undefined
172-
: await request.blob();
169+
let body: Blob | ReadableStream | undefined;
170+
171+
if (['GET', 'HEAD'].includes(request.method) || 'body' in overrides) {
172+
body = undefined;
173+
} else if (!request.bodyUsed && request.body) {
174+
// If the body hasn't been consumed yet, we can reuse the stream directly
175+
// This avoids the hang that occurs when trying to read from a stream
176+
// that's still waiting for more data
177+
body = request.body;
178+
} else {
179+
// Otherwise, we need to read the body as a blob
180+
body = await request.blob();
181+
}
173182

174183
return new Request(overrides['url'] || request.url, {
175184
body,
@@ -182,6 +191,9 @@ export async function cloneRequest(
182191
cache: request.cache,
183192
redirect: request.redirect,
184193
integrity: request.integrity,
194+
// In Node.js, duplex: 'half' is required when
195+
// the body is provided for non-GET/HEAD requests.
196+
...(body && { duplex: 'half' }),
185197
...overrides,
186198
});
187199
}

packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.spec.ts

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ import {
33
RawBytesFetch,
44
} from './tcp-over-fetch-websocket';
55
import express from 'express';
6-
import type http from 'http';
6+
import https from 'https';
77
import type { AddressInfo } from 'net';
88
import zlib from 'zlib';
9+
import {
10+
generateCertificate,
11+
cleanupCertificate,
12+
} from './test-utils/generate-certificate';
913

1014
const pygmalion = `PREFACE TO PYGMALION.
1115
@@ -58,16 +62,20 @@ least an ill-natured man: very much the opposite, I should say; but he
5862
would not suffer fools gladly.`;
5963

6064
describe('TCPOverFetchWebsocket', () => {
61-
let server: http.Server;
65+
let server: https.Server;
6266
let host: string;
6367
let port: number;
68+
let originalRejectUnauthorized: string | undefined;
6469

6570
beforeAll(async () => {
71+
// Allow self-signed certificates for testing
72+
originalRejectUnauthorized =
73+
process.env['NODE_TLS_REJECT_UNAUTHORIZED'];
74+
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
75+
6676
const app = express();
67-
server = app.listen(0);
68-
const address = server.address() as AddressInfo;
69-
host = `127.0.0.1`;
70-
port = address.port;
77+
78+
// Set up all routes BEFORE creating the server
7179
app.get('/simple', (req, res) => {
7280
res.send('Hello, World!');
7381
});
@@ -138,10 +146,31 @@ describe('TCPOverFetchWebsocket', () => {
138146
app.get('/error', (req, res) => {
139147
res.status(500).send('Internal Server Error');
140148
});
149+
150+
// Now create and start the HTTPS server
151+
const { cert, key } = generateCertificate();
152+
server = https.createServer({ cert, key }, app);
153+
154+
// Wait for server to start listening
155+
await new Promise<void>((resolve) => {
156+
server.listen(0, () => resolve());
157+
});
158+
159+
const address = server.address() as AddressInfo;
160+
host = `127.0.0.1`;
161+
port = address.port;
141162
});
142163

143164
afterAll(() => {
144165
server.close();
166+
cleanupCertificate();
167+
// Restore original NODE_TLS_REJECT_UNAUTHORIZED value
168+
if (originalRejectUnauthorized !== undefined) {
169+
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] =
170+
originalRejectUnauthorized;
171+
} else {
172+
delete process.env['NODE_TLS_REJECT_UNAUTHORIZED'];
173+
}
145174
});
146175

147176
it('should handle a simple HTTP request', async () => {
@@ -545,17 +574,30 @@ async function makeRequest({
545574
}
546575

547576
async function bufferResponse(socket: TCPOverFetchWebsocket): Promise<string> {
548-
return new Promise((resolve) => {
577+
return new Promise((resolve, reject) => {
549578
let response = '';
550-
socket.clientDownstream.readable.pipeTo(
551-
new WritableStream({
552-
write(chunk) {
553-
response += new TextDecoder().decode(chunk);
554-
},
555-
close() {
556-
resolve(response);
557-
},
558-
})
559-
);
579+
580+
// Add error listener
581+
socket.on('error', (error) => {
582+
reject(error);
583+
});
584+
585+
socket.clientDownstream.readable
586+
.pipeTo(
587+
new WritableStream({
588+
write(chunk) {
589+
response += new TextDecoder().decode(chunk);
590+
},
591+
close() {
592+
resolve(response);
593+
},
594+
abort(error) {
595+
reject(error);
596+
},
597+
})
598+
)
599+
.catch((error) => {
600+
reject(error);
601+
});
560602
});
561603
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import selfsigned from 'selfsigned';
2+
3+
let cachedCertificate: { cert: string; key: string; certPath: string } | null =
4+
null;
5+
6+
export function generateCertificate(): {
7+
cert: string;
8+
key: string;
9+
certPath: string;
10+
} {
11+
// Return cached certificate if already generated
12+
if (cachedCertificate) {
13+
return cachedCertificate;
14+
}
15+
16+
const attrs = [{ name: 'commonName', value: 'localhost' }];
17+
const options = {
18+
days: 365,
19+
keySize: 2048,
20+
algorithm: 'sha256',
21+
extensions: [
22+
{
23+
name: 'subjectAltName',
24+
altNames: [
25+
{ type: 2, value: 'localhost' }, // DNS name
26+
{ type: 7, ip: '127.0.0.1' }, // IP address
27+
],
28+
},
29+
],
30+
};
31+
const pems = selfsigned.generate(attrs, options);
32+
33+
// Cache the certificate for reuse
34+
// Note: We don't create a certificate file because NODE_EXTRA_CA_CERTS
35+
// can't be set dynamically. Instead, we rely on NODE_TLS_REJECT_UNAUTHORIZED=0
36+
cachedCertificate = {
37+
cert: pems.cert,
38+
key: pems.private,
39+
certPath: '', // Not used, but kept for interface compatibility
40+
};
41+
42+
return cachedCertificate;
43+
}
44+
45+
export function cleanupCertificate(): void {
46+
cachedCertificate = null;
47+
}

packages/playground/cli/tests/run-cli.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ describe.each(blueprintVersions)(
556556
'Starting a PHP server...',
557557
'Starting up workers',
558558
expect.stringMatching(
559-
/^Resolved WordPress release URL: https:\/\/downloads\.w\.org\/release\/wordpress-\d+\.\d+\.\d+\.zip$/
559+
/^Resolved WordPress release URL: https:\/\/downloads\.w(ordpress)?\.org\/release\/wordpress-\d+\.\d+(?:\.\d+|-RC\d+|-beta\d+)?\.zip$/
560560
),
561561
'Fetching SQLite integration plugin...',
562562
'Booting WordPress...',

0 commit comments

Comments
 (0)