From 7228af1c4e7c021f5875e34461c0f625047fd24d Mon Sep 17 00:00:00 2001 From: Adam Zielinski Date: Thu, 22 Jan 2026 00:49:05 +0100 Subject: [PATCH 01/23] [Website] Add peer-to-peer sharing via HTTP relay Enables sharing a Playground instance with others through an HTTP long-polling relay. The host browser processes WordPress requests and sends responses back through the relay to guest browsers. Key components: - Relay middleware for Vite dev server - TunnelHost class for host-side request processing - SharedPlaygroundViewer for guest-side rendering - Share modal UI with copy-to-clipboard functionality - URL rewriting for HTML, CSS, and redirect headers --- .../website/playwright/e2e/sharing.spec.ts | 287 +++++++++++ .../website/src/components/layout/index.tsx | 3 + .../src/components/share-modal/index.tsx | 248 ++++++++++ .../shared-playground-viewer/index.tsx | 144 ++++++ .../shared-playground-viewer/style.module.css | 157 ++++++ .../site-manager/site-info-panel/index.tsx | 5 + .../toolbar-buttons/share-menu-item.tsx | 25 + .../website/src/lib/relay-server/index.ts | 11 + .../src/lib/relay-server/relay-middleware.ts | 419 ++++++++++++++++ .../src/lib/relay-server/tunnel-host.ts | 466 ++++++++++++++++++ .../website/src/lib/relay-server/types.ts | 49 ++ .../website/src/lib/state/redux/slice-ui.ts | 1 + packages/playground/website/src/main.tsx | 28 +- packages/playground/website/vite.config.ts | 14 +- 14 files changed, 1848 insertions(+), 9 deletions(-) create mode 100644 packages/playground/website/playwright/e2e/sharing.spec.ts create mode 100644 packages/playground/website/src/components/share-modal/index.tsx create mode 100644 packages/playground/website/src/components/shared-playground-viewer/index.tsx create mode 100644 packages/playground/website/src/components/shared-playground-viewer/style.module.css create mode 100644 packages/playground/website/src/components/toolbar-buttons/share-menu-item.tsx create mode 100644 packages/playground/website/src/lib/relay-server/index.ts create mode 100644 packages/playground/website/src/lib/relay-server/relay-middleware.ts create mode 100644 packages/playground/website/src/lib/relay-server/tunnel-host.ts create mode 100644 packages/playground/website/src/lib/relay-server/types.ts diff --git a/packages/playground/website/playwright/e2e/sharing.spec.ts b/packages/playground/website/playwright/e2e/sharing.spec.ts new file mode 100644 index 00000000000..00ec2ff46a4 --- /dev/null +++ b/packages/playground/website/playwright/e2e/sharing.spec.ts @@ -0,0 +1,287 @@ +import { test, expect } from '../playground-fixtures.ts'; + +/** + * Helper function to open the "Additional actions" dropdown menu in the site info panel. + * The Share menu item is inside this dropdown, accessed via the three-dot (moreVertical) button. + */ +async function openAdditionalActionsMenu( + website: Awaited[1]>[0]['website']> +) { + // Click the "Additional actions" button (three-dot menu) in the site info panel + const additionalActionsButton = website.page.getByRole('button', { + name: 'Additional actions', + }); + await additionalActionsButton.click(); + // Wait for the dropdown menu to be visible + await website.page.waitForTimeout(200); +} + +test.describe('Sharing Feature', () => { + test('should display Share menu item in site manager dropdown', async ({ + website, + }) => { + await website.goto('./'); + await website.ensureSiteManagerIsOpen(); + + // Open the additional actions dropdown + await openAdditionalActionsMenu(website); + + // Look for the Share menu item in the dropdown + const shareMenuItem = website.page.getByRole('menuitem', { + name: /Share/, + }); + await expect(shareMenuItem).toBeVisible(); + }); + + test('should open share modal when Share is clicked', async ({ website }) => { + await website.goto('./'); + await website.ensureSiteManagerIsOpen(); + + // Open the additional actions dropdown and click Share + await openAdditionalActionsMenu(website); + const shareMenuItem = website.page.getByRole('menuitem', { + name: /Share/, + }); + await shareMenuItem.click(); + + // Verify the share modal is visible by looking for the dialog with the title + const shareModal = website.page.getByRole('dialog', { + name: 'Share Playground', + }); + await expect(shareModal).toBeVisible(); + + // Verify the Start Sharing button is visible + await expect( + website.page.getByRole('button', { name: 'Start Sharing' }) + ).toBeVisible(); + }); + + test('should start sharing and display share URL', async ({ + website, + context, + browserName, + }) => { + test.skip( + browserName === 'firefox', + 'Firefox does not support clipboard-read permission through Playwright' + ); + + // Grant clipboard permissions + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + + await website.goto('./'); + await website.ensureSiteManagerIsOpen(); + + // Open the additional actions dropdown and click Share + await openAdditionalActionsMenu(website); + const shareMenuItem = website.page.getByRole('menuitem', { + name: /Share/, + }); + await shareMenuItem.click(); + + // Click Start Sharing + await website.page.getByRole('button', { name: 'Start Sharing' }).click(); + + // Wait for the share URL to appear (the TextControl has label "Share URL") + const shareUrlInput = website.page.getByLabel('Share URL'); + await expect(shareUrlInput).toBeVisible({ timeout: 15000 }); + + // Verify the URL contains the share parameter + const shareUrl = await shareUrlInput.inputValue(); + expect(shareUrl).toContain('?share='); + + // Verify Stop Sharing button is now visible + await expect( + website.page.getByRole('button', { name: 'Stop Sharing' }) + ).toBeVisible(); + + // Verify Copy button is visible + await expect( + website.page.getByRole('button', { name: 'Copy' }) + ).toBeVisible(); + }); + + test('should stop sharing when Stop Sharing is clicked', async ({ + website, + }) => { + await website.goto('./'); + await website.ensureSiteManagerIsOpen(); + + // Open the additional actions dropdown and click Share + await openAdditionalActionsMenu(website); + const shareMenuItem = website.page.getByRole('menuitem', { + name: /Share/, + }); + await shareMenuItem.click(); + + // Click Start Sharing + await website.page.getByRole('button', { name: 'Start Sharing' }).click(); + + // Wait for sharing to start + await expect( + website.page.getByRole('button', { name: 'Stop Sharing' }) + ).toBeVisible({ timeout: 15000 }); + + // Click Stop Sharing + await website.page.getByRole('button', { name: 'Stop Sharing' }).click(); + + // Verify Start Sharing button is visible again + await expect( + website.page.getByRole('button', { name: 'Start Sharing' }) + ).toBeVisible({ timeout: 5000 }); + }); + + test('should copy share URL to clipboard when Copy is clicked', async ({ + website, + context, + browserName, + }) => { + test.skip( + browserName === 'firefox', + 'Firefox does not support clipboard-read permission through Playwright' + ); + + // Grant clipboard permissions + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + + await website.goto('./'); + await website.ensureSiteManagerIsOpen(); + + // Open the additional actions dropdown and click Share + await openAdditionalActionsMenu(website); + const shareMenuItem = website.page.getByRole('menuitem', { + name: /Share/, + }); + await shareMenuItem.click(); + + // Click Start Sharing + await website.page.getByRole('button', { name: 'Start Sharing' }).click(); + + // Wait for sharing to start + await expect( + website.page.getByRole('button', { name: 'Copy' }) + ).toBeVisible({ timeout: 15000 }); + + // Get the share URL from the input + const shareUrlInput = website.page.getByLabel('Share URL'); + const expectedUrl = await shareUrlInput.inputValue(); + + // Click Copy + await website.page.getByRole('button', { name: 'Copy' }).click(); + + // Verify clipboard contains the share URL + const clipboardContent = await website.page.evaluate(() => + navigator.clipboard.readText() + ); + expect(clipboardContent).toBe(expectedUrl); + }); + + test.describe('Guest Viewing', () => { + test('should display shared playground viewer for guests', async ({ + page, + }) => { + // Create a fake share session ID + const fakeSessionId = 'test-session-' + Date.now(); + + // Navigate directly to a share URL (this will show connecting state + // since the session doesn't exist) + await page.goto(`./?share=${fakeSessionId}`); + + // Verify the shared playground viewer is displayed + await expect( + page.locator('text=Viewing a shared Playground') + ).toBeVisible(); + + // Verify the "Create your own Playground" link is visible + await expect( + page.getByRole('link', { name: 'Create your own Playground' }) + ).toBeVisible(); + }); + + test('should navigate to regular playground when clicking Create your own', async ({ + page, + }) => { + // Create a fake share session ID + const fakeSessionId = 'test-session-' + Date.now(); + + // Navigate to a share URL + await page.goto(`./?share=${fakeSessionId}`); + + // Wait for the viewer to load + await expect( + page.locator('text=Viewing a shared Playground') + ).toBeVisible(); + + // Click "Create your own Playground" link + const createOwnLink = page.getByRole('link', { + name: 'Create your own Playground', + }); + await createOwnLink.click(); + + // Verify we're now on the regular playground page (no share parameter) + await page.waitForURL((url) => !url.searchParams.has('share')); + }); + }); + + test.describe('End-to-end sharing flow', () => { + test('should allow guest to view host playground through relay', async ({ + website, + context, + }) => { + // Start host sharing + await website.goto('./'); + await website.ensureSiteManagerIsOpen(); + + // Open the additional actions dropdown and click Share + await openAdditionalActionsMenu(website); + const shareMenuItem = website.page.getByRole('menuitem', { + name: /Share/, + }); + await shareMenuItem.click(); + + await website.page + .getByRole('button', { name: 'Start Sharing' }) + .click(); + + // Wait for share URL + const shareUrlInput = website.page.getByLabel('Share URL'); + await expect(shareUrlInput).toBeVisible({ timeout: 20000 }); + + const shareUrl = await shareUrlInput.inputValue(); + expect(shareUrl).toContain('?share='); + + // Open a new page as guest + const guestPage = await context.newPage(); + await guestPage.goto(shareUrl); + + // Verify guest sees the shared playground viewer + await expect( + guestPage.locator('text=Viewing a shared Playground') + ).toBeVisible(); + + // Wait for connection to be established + await expect(guestPage.locator('text=Connected')).toBeVisible({ + timeout: 30000, + }); + + // Verify the iframe is loaded with WordPress content + const guestIframe = guestPage.frameLocator( + 'iframe[title="Shared WordPress Playground"]' + ); + + // Wait for WordPress content to load through the relay + // The guest should see the WordPress site with the admin bar + await expect(guestIframe.locator('#wpadminbar')).toBeVisible({ + timeout: 30000, + }); + + // Clean up + await guestPage.close(); + + // Stop sharing + await website.page + .getByRole('button', { name: 'Stop Sharing' }) + .click(); + }); + }); +}); diff --git a/packages/playground/website/src/components/layout/index.tsx b/packages/playground/website/src/components/layout/index.tsx index 3420ee95c1f..9f400c93ee2 100644 --- a/packages/playground/website/src/components/layout/index.tsx +++ b/packages/playground/website/src/components/layout/index.tsx @@ -23,6 +23,7 @@ import { SaveSiteModal } from '../save-site-modal'; import { modalSlugs } from '../../lib/state/redux/slice-ui'; import { GitHubPrivateRepoAuthModal } from '../github-private-repo-auth-modal'; import { BlueprintUrlModal } from '../blueprint-url-modal'; +import { ShareModal } from '../share-modal'; import { ModalLoadingFallback } from '../modal-loading-fallback'; /** @@ -173,6 +174,8 @@ function Modals() { return ; } else if (currentModal === modalSlugs.BLUEPRINT_URL) { return ; + } else if (currentModal === modalSlugs.SHARE_PLAYGROUND) { + return ; } if (currentModal === modalSlugs.PREVIEW_PR_WP) { diff --git a/packages/playground/website/src/components/share-modal/index.tsx b/packages/playground/website/src/components/share-modal/index.tsx new file mode 100644 index 00000000000..8d2a8b12cdc --- /dev/null +++ b/packages/playground/website/src/components/share-modal/index.tsx @@ -0,0 +1,248 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Button, TextControl, Notice } from '@wordpress/components'; +import { copy, check } from '@wordpress/icons'; +import { Modal } from '../modal'; +import ModalButtons from '../modal/modal-buttons'; +import { useAppDispatch, useAppSelector } from '../../lib/state/redux/store'; +import { setActiveModal } from '../../lib/state/redux/slice-ui'; +import { selectClientInfoBySiteSlug } from '../../lib/state/redux/slice-clients'; +import { TunnelHost, type TunnelHostStatus } from '../../lib/relay-server'; + +type ShareState = 'idle' | 'connecting' | 'sharing' | 'error'; + +export function ShareModal() { + const dispatch = useAppDispatch(); + const [shareState, setShareState] = useState('idle'); + const [shareUrl, setShareUrl] = useState(null); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const [tunnelHost, setTunnelHost] = useState(null); + + const clientInfo = useAppSelector((state) => + state.ui.activeSite?.slug + ? selectClientInfoBySiteSlug(state, state.ui.activeSite.slug) + : undefined + ); + const playground = clientInfo?.client; + + const closeModal = useCallback(() => { + dispatch(setActiveModal(null)); + }, [dispatch]); + + // Clean up tunnel host when modal closes + useEffect(() => { + return () => { + if (tunnelHost) { + tunnelHost.stopSharing(); + } + }; + }, [tunnelHost]); + + const handleStatusChange = useCallback((status: TunnelHostStatus) => { + switch (status) { + case 'connecting': + setShareState('connecting'); + break; + case 'connected': + setShareState('sharing'); + break; + case 'disconnected': + setShareState('idle'); + setShareUrl(null); + break; + case 'error': + setShareState('error'); + break; + } + }, []); + + const handleError = useCallback((err: Error) => { + setError(err.message); + setShareState('error'); + }, []); + + const startSharing = async () => { + if (!playground) { + setError('Playground is not ready'); + return; + } + + setShareState('connecting'); + setError(null); + + try { + // Determine relay URL based on current location + const relayUrl = window.location.origin; + const host = new TunnelHost(playground, relayUrl); + + host.on('statusChange', handleStatusChange); + host.on('error', handleError); + + setTunnelHost(host); + + const url = await host.startSharing(); + setShareUrl(url); + setShareState('sharing'); + } catch (err) { + setError((err as Error).message); + setShareState('error'); + } + }; + + const stopSharing = async () => { + if (tunnelHost) { + await tunnelHost.stopSharing(); + setTunnelHost(null); + } + setShareUrl(null); + setShareState('idle'); + setError(null); + }; + + const copyToClipboard = async () => { + if (shareUrl) { + await navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const handleRequestClose = () => { + if (shareState !== 'connecting') { + closeModal(); + } + }; + + return ( + +
+ {shareState === 'idle' && ( + <> +

+ Share your Playground with others in real-time. + They'll be able to view and interact with your + WordPress site through their browser. +

+ + Your Playground will be accessible to anyone with + the share link while sharing is active. + + + )} + + {shareState === 'connecting' && ( +

+ Setting up sharing session... +

+ )} + + {shareState === 'sharing' && shareUrl && ( + <> + + Your Playground is being shared! Copy the link below + to share it with others. + +
+ + (e.target as HTMLInputElement).select() + } + /> + +
+

+ Keep this window open to maintain the sharing + session. Closing it will disconnect all guests. +

+ + )} + + {shareState === 'error' && error && ( + + {error} + + )} + +
+ {shareState === 'idle' && ( + + )} + + {shareState === 'connecting' && ( + + )} + + {shareState === 'sharing' && ( + <> + + + + )} + + {shareState === 'error' && ( + + )} +
+
+
+ ); +} diff --git a/packages/playground/website/src/components/shared-playground-viewer/index.tsx b/packages/playground/website/src/components/shared-playground-viewer/index.tsx new file mode 100644 index 00000000000..d9fe503dd27 --- /dev/null +++ b/packages/playground/website/src/components/shared-playground-viewer/index.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import css from './style.module.css'; + +interface SharedPlaygroundViewerProps { + sessionId: string; +} + +type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected'; + +export function SharedPlaygroundViewer({ + sessionId, +}: SharedPlaygroundViewerProps) { + const [status, setStatus] = useState('connecting'); + const [error, setError] = useState(null); + const iframeRef = useRef(null); + + // The relay request URL for this session + const relayBaseUrl = `${window.location.origin}/relay/${sessionId}/request`; + + // Check if the session is valid by making a test request + useEffect(() => { + const checkSession = async () => { + try { + // Try to reach the host through the relay + const response = await fetch(`${relayBaseUrl}/`, { + method: 'GET', + headers: { + Accept: 'text/html', + }, + }); + + if (response.ok) { + setStatus('connected'); + } else if (response.status === 503) { + setError('The host is not connected. Please try again later.'); + setStatus('error'); + } else if (response.status === 404) { + setError('This sharing session has expired or does not exist.'); + setStatus('error'); + } else { + setError(`Connection failed: ${response.statusText}`); + setStatus('error'); + } + } catch (err) { + setError( + 'Unable to connect to the shared Playground. Please check your connection.' + ); + setStatus('error'); + } + }; + + checkSession(); + }, [relayBaseUrl]); + + const handleIframeLoad = useCallback(() => { + setStatus('connected'); + }, []); + + const handleRetry = () => { + setStatus('connecting'); + setError(null); + // Force iframe reload + if (iframeRef.current) { + iframeRef.current.src = `${relayBaseUrl}/`; + } + }; + + return ( +
+
+
+ 👁️ + + Viewing a shared Playground + + {status === 'connected' && ( + ● Connected + )} + {status === 'connecting' && ( + + ● Connecting... + + )} +
+ + Create your own Playground + +
+ + {status === 'error' && error && ( +
+
+

Unable to Connect

+

{error}

+
+ + + Go to Playground + +
+
+
+ )} + + {status === 'connecting' && ( +
+
+
+

Connecting to shared Playground...

+
+
+ )} + + {(status === 'connected' || status === 'connecting') && ( +