Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7228af1
[Website] Add peer-to-peer sharing via HTTP relay
adamziel Jan 21, 2026
bfd7825
[Website] Add PHP relay, sharing service, and UI indicator
adamziel Jan 22, 2026
2efbf88
Fix: Remove server-only exports from relay-server index
adamziel Jan 22, 2026
2a032c1
Fix relay long-poll cleanup leaking stale resolvers
adamziel Apr 7, 2026
53e25fa
Tell the guest when the sharing host disappears
adamziel Apr 7, 2026
213e14a
Put the Share button where people can actually see it
adamziel Apr 7, 2026
e099be5
Show who is watching your shared Playground
adamziel Apr 7, 2026
3420509
Migrate dev env to PHP relay middleware
adamziel Apr 7, 2026
17afcb2
Hold the first guest request until the host actually polls
adamziel Apr 7, 2026
c95c53b
Drop the leftover PHP-WASM relay bridge
adamziel Apr 7, 2026
9e55fca
Make the share code lint-clean for CI
adamziel Apr 7, 2026
c2782f5
Run the PHP relay during CI e2e runs too
adamziel Apr 7, 2026
6092226
Make the share e2e tests work on webkit too
adamziel Apr 7, 2026
094b6b9
Make Copy work on every browser, not just chromium
adamziel Apr 7, 2026
2444e95
Let relay.php run on a database, not just files
adamziel Apr 7, 2026
4052961
Run the share e2e tests against the MySQL relay backend in CI
adamziel Apr 7, 2026
e6a0d96
Prove the relay actually carries live edits between users
adamziel Apr 8, 2026
83d8a04
Wait for the remote dev server before starting the website one
adamziel Apr 8, 2026
90d2103
Quiet down the relay PHP server in the dev terminal
adamziel Apr 8, 2026
1647ab4
Stop overlapping /status fetches in the guest viewer
adamziel Apr 8, 2026
20a5c0c
Stop the host from dispatching tunnel requests in parallel
adamziel Apr 8, 2026
20ef62a
Stop walking guest HTML responses with regular expressions
adamziel Apr 8, 2026
021b252
Make Stop Sharing actually stop, and cap how much we'll buffer
adamziel Apr 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,23 @@ jobs:
# all shards to start immediately in parallel, reducing total wall time.
test-e2e-playwright:
runs-on: ubuntu-latest
# Spin up a real MySQL alongside the runner so the share-relay
# PHP backend (relay.php) is exercised end-to-end against the
# mysql storage class. The credentials below are fed to the
# relay process via the env: block on the playwright step.
services:
mysql:
image: mysql:8
env:
MYSQL_ROOT_PASSWORD: relay
MYSQL_DATABASE: relay
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h localhost -uroot -prelay"
--health-interval=5s
--health-timeout=2s
--health-retries=20
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -243,6 +260,25 @@ jobs:
with:
submodules: true
- uses: ./.github/actions/prepare-playground
- name: Install pdo_mysql for the relay backend
# The runner ships with PHP but pdo_mysql isn't always
# enabled by default. The relay process opens its own
# PDO connection so it needs the extension loaded.
run: |
sudo apt-get update
sudo apt-get install -y php-mysql
php -m | grep -i pdo_mysql
- name: Wait for MySQL to accept connections
run: |
for i in $(seq 1 30); do
if mysqladmin ping -h 127.0.0.1 -P 3306 -uroot -prelay --silent 2>/dev/null; then
echo "MySQL ready"
exit 0
fi
sleep 1
done
echo "MySQL never came up"
exit 1
- name: Install Playwright Browser
run: sudo npx playwright install ${{ matrix.browser }} --with-deps
- name: Build app for E2E tests
Expand All @@ -253,10 +289,18 @@ jobs:
if [ -n "${{ matrix.shard }}" ]; then
SHARD_ARG="--shard=${{ matrix.shard }}"
fi
# Switch the relay subprocess (php -S relay.php,
# spawned by run-ci-preview-with-cors-proxy.cjs) onto
# the mysql storage backend by passing PLAYGROUND_RELAY_BACKEND
# and the DB_* credentials on the sudo command line.
# We pass them explicitly (rather than relying on
# `sudo -E`) because Ubuntu's default sudoers policy
# resets the environment.
RELAY_ENV="PLAYGROUND_RELAY_BACKEND=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=root DB_PASSWORD=relay DB_NAME=relay"
if [ "${{ matrix.browser }}" = "firefox" ]; then
sudo -E HOME=/root XDG_RUNTIME_DIR=/root CI=true npx playwright test --config=packages/playground/website/playwright/playwright.ci.config.ts --project=${{ matrix.browser }} $SHARD_ARG
sudo -E HOME=/root XDG_RUNTIME_DIR=/root CI=true $RELAY_ENV npx playwright test --config=packages/playground/website/playwright/playwright.ci.config.ts --project=${{ matrix.browser }} $SHARD_ARG
else
sudo CI=true npx playwright test --config=packages/playground/website/playwright/playwright.ci.config.ts --project=${{ matrix.browser }} $SHARD_ARG
sudo CI=true $RELAY_ENV npx playwright test --config=packages/playground/website/playwright/playwright.ci.config.ts --project=${{ matrix.browser }} $SHARD_ARG
fi
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
Expand Down
7 changes: 6 additions & 1 deletion packages/playground/remote/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,12 @@ self.addEventListener('fetch', (event) => {

const isReservedUrl =
url.pathname.startsWith('/plugin-proxy') ||
url.pathname.startsWith('/client/index.js');
url.pathname.startsWith('/client/index.js') ||
// Peer-to-peer sharing: the /relay/* endpoints are handled by the
// website's relay middleware (or relay.php in production). The
// service worker must let those pass through to the network so
// the host can create sessions and guests can poll status.
url.pathname.startsWith('/relay/');
if (isReservedUrl) {
return;
}
Expand Down
85 changes: 85 additions & 0 deletions packages/playground/website/bin/quiet-php-server.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env node

/**
* Wraps `php -S` and filters out the noisy startup/teardown chatter
* the built-in server logs to stderr — one "Development Server
* started" banner per worker (×20 with PHP_CLI_SERVER_WORKERS=20),
* plus per-request "Accepted" / "Closing" lines and the spurious
* "Failed to poll event" warning we get when PHP is built against
* libevent and has nothing to do.
*
* Usage: node quiet-php-server.cjs <host:port> <script.php>
*
* The wrapper inherits the parent process's environment so the
* relay's PHP_CLI_SERVER_WORKERS, PLAYGROUND_RELAY_PUBLIC_BASE_URL,
* DB_*, etc. all get through unchanged.
*/

const { spawn } = require('child_process');

const address = process.argv[2];
const script = process.argv[3];

if (!address || !script) {
console.error('usage: quiet-php-server.cjs <host:port> <script.php>');
process.exit(2);
}

/**
* Lines we never want to see in the dev terminal. Anything else —
* real PHP errors, our own error_log() calls, unfamiliar warnings —
* still gets forwarded to stderr so the developer sees it.
*/
const noisePatterns = [
/PHP \d+\.\d+\.\d+ Development Server \(http:\/\/[^)]+\) started/,
/\[[^\]]+\] [\d.]+:\d+ Accepted$/,
/\[[^\]]+\] [\d.]+:\d+ Closing$/,
/\[[^\]]+\] Failed to poll event$/,
];

function isNoise(line) {
const trimmed = line.replace(/^\[\d+\] /, '');
return noisePatterns.some((p) => p.test(trimmed));
}

const child = spawn('php', ['-S', address, script], {
stdio: ['inherit', 'inherit', 'pipe'],
env: process.env,
});

let buffer = '';
child.stderr.on('data', (chunk) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!isNoise(line)) {
process.stderr.write(line + '\n');
}
}
});
child.stderr.on('end', () => {
if (buffer && !isNoise(buffer)) {
process.stderr.write(buffer);
}
});

child.on('error', (err) => {
console.error('quiet-php-server: failed to spawn php:', err.message);
process.exit(1);
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
} else {
process.exit(code ?? 0);
}
});

for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, () => {
if (!child.killed) {
child.kill(sig);
}
});
}
39 changes: 27 additions & 12 deletions packages/playground/website/bin/run-ci-preview-with-cors-proxy.cjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
#!/usr/bin/env node

/**
* Starts the PHP CORS proxy and the preview server for CI e2e runs.
* Ensures the proxy binds to its port before starting the preview server
* and exits immediately if either child process crashes.
* Starts the PHP CORS proxy, the PHP relay server, and the preview
* server for CI e2e runs. Ensures the proxy and relay both bind to
* their ports before starting the preview server, and exits
* immediately if any child process crashes.
*
* The relay is needed for the share Playground tests in
* sharing.spec.ts: the static preview build can serve relay.php as
* a file but can't execute it, so we run a real `php -S` next to
* the cors-proxy and let the vite preview proxy /relay/* into it.
*/
const { spawn } = require('child_process');
const net = require('net');
Expand All @@ -14,6 +20,8 @@ const workspaceRoot =
path.resolve(__dirname, '../../../..');
const corsProxyHost = process.env.CORS_PROXY_HOST ?? '127.0.0.1';
const corsProxyPort = Number(process.env.CORS_PROXY_PORT ?? '5263');
const relayHost = process.env.RELAY_HOST ?? '127.0.0.1';
const relayPort = Number(process.env.RELAY_PORT ?? '5264');
const waitTimeoutMs = Number(
process.env.CORS_PROXY_READY_TIMEOUT_MS ?? '15000'
);
Expand All @@ -25,6 +33,8 @@ const nodeBinary = process.execPath;
/** @type {import('child_process').ChildProcess | null} */
let proxyProcess = null;
/** @type {import('child_process').ChildProcess | null} */
let relayProcess = null;
/** @type {import('child_process').ChildProcess | null} */
let previewProcess = null;
let shuttingDown = false;

Expand All @@ -40,12 +50,9 @@ function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function checkPort() {
function checkPort(host, port) {
return new Promise((resolve, reject) => {
const socket = net.createConnection({
port: corsProxyPort,
host: corsProxyHost,
});
const socket = net.createConnection({ port, host });
socket.once('connect', () => {
socket.end();
resolve();
Expand All @@ -57,16 +64,16 @@ function checkPort() {
});
}

async function waitForProxy() {
async function waitForPort(name, host, port) {
const start = Date.now();
for (;;) {
try {
await checkPort();
await checkPort(host, port);
return;
} catch {
if (Date.now() - start > waitTimeoutMs) {
throw new Error(
`Timed out waiting for playground-php-cors-proxy to bind on ${corsProxyHost}:${corsProxyPort}`
`Timed out waiting for ${name} to bind on ${host}:${port}`
);
}
await wait(waitIntervalMs);
Expand Down Expand Up @@ -109,6 +116,7 @@ function cleanupChild(child) {
function cleanupAndExit(code) {
shuttingDown = true;
cleanupChild(previewProcess);
cleanupChild(relayProcess);
cleanupChild(proxyProcess);
process.exit(code);
}
Expand All @@ -117,14 +125,21 @@ process.once('SIGINT', () => cleanupAndExit(130));
process.once('SIGTERM', () => cleanupAndExit(143));
process.once('exit', () => {
cleanupChild(previewProcess);
cleanupChild(relayProcess);
cleanupChild(proxyProcess);
});

async function main() {
proxyProcess = spawnNxTarget('playground-php-cors-proxy:start');
registerProcessHooks(proxyProcess, 'playground-php-cors-proxy');

await waitForProxy().catch((error) => {
relayProcess = spawnNxTarget('playground-website:preview:relay-php');
registerProcessHooks(relayProcess, 'playground-website:preview:relay-php');

await Promise.all([
waitForPort('playground-php-cors-proxy', corsProxyHost, corsProxyPort),
waitForPort('playground-website:preview:relay-php', relayHost, relayPort),
]).catch((error) => {
console.error(error.message);
cleanupAndExit(1);
});
Expand Down
46 changes: 46 additions & 0 deletions packages/playground/website/bin/wait-for-tcp.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env node

/**
* Tiny port-readiness check used by `npm run dev` to make sure the
* remote dev server (port 4400) is accepting connections before the
* main website server starts proxying /manifest.json and friends to
* it. Without it, the very first request after `npm run dev` races
* the remote server's startup and hits ECONNREFUSED, surfaced by
* vite as a confusing `http proxy error: /manifest.json` log line
* even though everything self-heals on the next request.
*
* Usage: node wait-for-tcp.cjs <host> <port> [timeoutMs]
*/

const net = require('net');

const host = process.argv[2];
const port = Number(process.argv[3]);
const timeoutMs = Number(process.argv[4] ?? 30000);

if (!host || !Number.isFinite(port)) {
console.error('usage: wait-for-tcp.cjs <host> <port> [timeoutMs]');
process.exit(2);
}

const start = Date.now();

function tryOnce() {
const socket = net.connect(port, host);
socket.once('connect', () => {
socket.end();
process.exit(0);
});
socket.once('error', () => {
socket.destroy();
if (Date.now() - start > timeoutMs) {
console.error(
`wait-for-tcp: timed out after ${timeoutMs}ms waiting for ${host}:${port}`
);
process.exit(1);
}
setTimeout(tryOnce, 200);
});
}

tryOnce();
Loading
Loading