diff --git a/packages/php-wasm/progress/src/lib/emscripten-download-monitor.ts b/packages/php-wasm/progress/src/lib/emscripten-download-monitor.ts index bde572e8fa..c30a3c74f8 100644 --- a/packages/php-wasm/progress/src/lib/emscripten-download-monitor.ts +++ b/packages/php-wasm/progress/src/lib/emscripten-download-monitor.ts @@ -161,10 +161,12 @@ export function cloneStreamMonitorProgress( ); } - return new ReadableStream({ + let closed = false; + const monitoredStream = new ReadableStream({ async start(controller) { if (!stream) { controller.close(); + closed = true; return; } const reader = stream.getReader(); @@ -178,19 +180,29 @@ export function cloneStreamMonitorProgress( if (done) { notify(loaded, loaded); controller.close(); + closed = true; break; } else { notify(loaded, total); - controller.enqueue(value); + if (!closed) { + controller.enqueue(value); + } } } catch (e) { - logger.error({ e }); - controller.error(e); + debugger; + try { + console.log(controller); + logger.error({ e }); + console.dir(e); + controller.close(); + } catch (e) {} + // controller.error(e); break; } } }, }); + return monitoredStream; } export type DownloadProgressCallback = (progress: DownloadProgress) => void; diff --git a/packages/playground/blueprints/src/lib/resolve-remote-blueprint.ts b/packages/playground/blueprints/src/lib/resolve-remote-blueprint.ts index b9e4a141dd..6cbc8d70c9 100644 --- a/packages/playground/blueprints/src/lib/resolve-remote-blueprint.ts +++ b/packages/playground/blueprints/src/lib/resolve-remote-blueprint.ts @@ -19,7 +19,23 @@ export async function resolveRemoteBlueprint( credentials: 'omit', }); if (!response.ok) { - throw new Error(`Failed to fetch blueprint from ${url}`); + const statusText = response.statusText?.trim(); + const statusSummary = statusText + ? `${response.status} ${statusText}` + : `${response.status}`; + const error = new Error( + response.status + ? `Failed to fetch the Blueprint. The server responded with HTTP ${statusSummary}.` + : 'Failed to fetch the Blueprint.' + ) as Error & { + status?: number; + statusText?: string; + url?: string; + }; + error.status = response.status; + error.statusText = response.statusText; + error.url = url; + throw error; } const blueprintBytes = await response.arrayBuffer(); try { @@ -41,9 +57,11 @@ export async function resolveRemoteBlueprint( if (await looksLikeZipFile(blueprintBytes)) { return ZipFilesystem.fromArrayBuffer(blueprintBytes); } - throw new Error( + const error = new Error( `Blueprint file at ${url} is neither a valid JSON nor a ZIP file.` - ); + ) as Error & { url?: string }; + error.url = url; + throw error; } } diff --git a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx index aea36cef8f..382e19c527 100644 --- a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx +++ b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx @@ -17,7 +17,10 @@ import { redirectTo } from '../../lib/state/url/router'; import { logger } from '@php-wasm/logger'; import { usePrevious } from '../../lib/hooks/use-previous'; import { modalSlugs } from '../layout'; -import { setActiveModal } from '../../lib/state/redux/slice-ui'; +import { + setActiveModal, + setActiveSiteError, +} from '../../lib/state/redux/slice-ui'; import { selectClientBySiteSlug } from '../../lib/state/redux/slice-clients'; import { randomSiteName } from '../../lib/state/redux/random-site-name'; @@ -158,8 +161,18 @@ async function createNewTemporarySite( const siteName = requestedSiteSlug ? deriveSiteNameFromSlug(requestedSiteSlug) : randomSiteName(); - const newSiteInfo = await dispatch( + const { site, blueprintResolutionFailed } = await dispatch( setTemporarySiteSpec(siteName, new URL(window.location.href)) ); - await dispatch(setActiveSite(newSiteInfo.slug)); + await dispatch(setActiveSite(site.slug)); + if (blueprintResolutionFailed) { + dispatch( + setActiveSiteError({ + error: 'blueprint-resolution-failed', + context: { + blueprintResolution: blueprintResolutionFailed, + }, + }) + ); + } } diff --git a/packages/playground/website/src/components/playground-viewport/index.tsx b/packages/playground/website/src/components/playground-viewport/index.tsx index 23be0e3951..9b5b47cad9 100644 --- a/packages/playground/website/src/components/playground-viewport/index.tsx +++ b/packages/playground/website/src/components/playground-viewport/index.tsx @@ -4,19 +4,24 @@ import css from './style.module.css'; import BrowserChrome from '../browser-chrome'; import { selectActiveSiteError, + selectActiveSiteErrorContext, useActiveSite, useAppDispatch, useAppSelector, } from '../../lib/state/redux/store'; import { removeClientInfo } from '../../lib/state/redux/slice-clients'; import { bootSiteClient } from '../../lib/state/redux/boot-site-client'; -import type { SiteError } from '../../lib/state/redux/slice-ui'; +import type { + ActiveSiteErrorContext, + SiteError, +} from '../../lib/state/redux/slice-ui'; import { Button, Spinner } from '@wordpress/components'; import { removeSite, selectSiteBySlug, selectSitesLoaded, selectTemporarySites, + DEFAULT_WELCOME_BLUEPRINT_URL, } from '../../lib/state/redux/slice-sites'; import classNames from 'classnames'; @@ -202,12 +207,17 @@ export const JustViewport = function JustViewport({ }, [siteSlug, iframeRef, runtimeConfigString]); const error = useAppSelector(selectActiveSiteError); + const errorContext = useAppSelector(selectActiveSiteErrorContext); if (error) { return (
- +
); @@ -226,11 +236,88 @@ export const JustViewport = function JustViewport({ function SiteErrorMessage({ error, siteSlug, + errorContext, }: { error: SiteError; siteSlug: string; + errorContext?: ActiveSiteErrorContext; }) { const dispatch = useAppDispatch(); + if (error === 'blueprint-resolution-failed') { + const blueprintError = errorContext?.blueprintResolution; + const attemptedUrl = + blueprintError?.attemptedUrl || DEFAULT_WELCOME_BLUEPRINT_URL; + const rawStatusText = + typeof blueprintError?.statusText === 'string' + ? blueprintError.statusText.trim() + : undefined; + const statusSummary = + typeof blueprintError?.httpStatus === 'number' + ? `HTTP ${blueprintError.httpStatus}${ + rawStatusText ? ` ${rawStatusText}` : '' + }` + : rawStatusText; + const trimmedMessage = + typeof blueprintError?.message === 'string' + ? blueprintError.message.trim() + : undefined; + const shouldShowMessage = + trimmedMessage && + trimmedMessage.length > 0 && + trimmedMessage !== statusSummary && + trimmedMessage !== attemptedUrl; + return ( + <> +

We couldn't load that Blueprint

+

+ WordPress Playground couldn't load the Blueprint below. The + file might be unavailable or invalid. +

+ {attemptedUrl ? ( +

+ + {attemptedUrl} + +

+ ) : null} + {statusSummary ? ( +

{statusSummary}

+ ) : null} + {shouldShowMessage ?

{trimmedMessage}

: null} +

+ Reload without a Blueprint to start with a blank WordPress + site. +

+ + + ); + } if ( error === 'directory-handle-not-found-in-indexeddb' || error === 'directory-handle-permission-denied' diff --git a/packages/playground/website/src/components/playground-viewport/style.module.css b/packages/playground/website/src/components/playground-viewport/style.module.css index 4a40c29cf9..9e3d191067 100644 --- a/packages/playground/website/src/components/playground-viewport/style.module.css +++ b/packages/playground/website/src/components/playground-viewport/style.module.css @@ -37,6 +37,23 @@ } } +.blueprint-url-wrapper { + margin-bottom: 15px; + word-break: break-word; + overflow-wrap: anywhere; +} + +.blueprint-url { + display: inline-block; + word-break: break-word; + overflow-wrap: anywhere; +} + +.blueprintStatus { + font-weight: 600; + margin-bottom: 15px; +} + .hidden { display: none; } diff --git a/packages/playground/website/src/lib/state/redux/boot-site-client.ts b/packages/playground/website/src/lib/state/redux/boot-site-client.ts index 7f5e87e613..810ff6d993 100644 --- a/packages/playground/website/src/lib/state/redux/boot-site-client.ts +++ b/packages/playground/website/src/lib/state/redux/boot-site-client.ts @@ -57,9 +57,9 @@ export function bootSiteClient( } catch (e) { logger.error(e); dispatch( - setActiveSiteError( - 'directory-handle-not-found-in-indexeddb' - ) + setActiveSiteError({ + error: 'directory-handle-not-found-in-indexeddb', + }) ); return; } @@ -82,13 +82,17 @@ export function bootSiteClient( logger.error(e); if (e instanceof DOMException && e.name === 'NotFoundError') { dispatch( - setActiveSiteError( - 'directory-handle-not-found-in-indexeddb' - ) + setActiveSiteError({ + error: 'directory-handle-not-found-in-indexeddb', + }) ); return; } - dispatch(setActiveSiteError('directory-handle-unknown-error')); + dispatch( + setActiveSiteError({ + error: 'directory-handle-unknown-error', + }) + ); return; } } @@ -197,9 +201,11 @@ export function bootSiteClient( (e as any).name === 'ArtifactExpiredError' || (e as any).originalErrorClassName === 'ArtifactExpiredError' ) { - dispatch(setActiveSiteError('github-artifact-expired')); + dispatch( + setActiveSiteError({ error: 'github-artifact-expired' }) + ); } else { - dispatch(setActiveSiteError('site-boot-failed')); + dispatch(setActiveSiteError({ error: 'site-boot-failed' })); dispatch(setActiveModal(modalSlugs.ERROR_REPORT)); } return; diff --git a/packages/playground/website/src/lib/state/redux/slice-sites.ts b/packages/playground/website/src/lib/state/redux/slice-sites.ts index b776f5d137..023581036a 100644 --- a/packages/playground/website/src/lib/state/redux/slice-sites.ts +++ b/packages/playground/website/src/lib/state/redux/slice-sites.ts @@ -20,6 +20,7 @@ import { applyQueryOverrides, } from '../url/resolve-blueprint-from-url'; import { logger } from '@php-wasm/logger'; +import type { ActiveSiteErrorContext } from './slice-ui'; /** * The Site model used to represent a site within Playground. @@ -238,6 +239,18 @@ export function removeSite(slug: string) { * @param siteInfo The site info to add. * @returns */ +export const DEFAULT_WELCOME_BLUEPRINT_URL = + 'https://raw.githubusercontent.com/WordPress/blueprints/refs/heads/trunk/blueprints/welcome/blueprint.json'; + +export interface SetTemporarySiteSpecResult { + site: SiteInfo; + blueprintResolutionFailed?: ActiveSiteErrorContext['blueprintResolution']; +} + +type BlueprintResolutionFailure = NonNullable< + ActiveSiteErrorContext['blueprintResolution'] +>; + export function setTemporarySiteSpec( siteName: string, playgroundUrlWithQueryApiArgs: URL @@ -245,7 +258,7 @@ export function setTemporarySiteSpec( return async ( dispatch: PlaygroundDispatch, getState: () => PlaygroundReduxState - ) => { + ): Promise => { const newSiteUrlParams = { searchParams: parseSearchParams( playgroundUrlWithQueryApiArgs.searchParams @@ -261,7 +274,7 @@ export function setTemporarySiteSpec( JSON.stringify(currentTemporarySite.originalUrlParams) === JSON.stringify(newSiteUrlParams) ) { - return currentTemporarySite; + return { site: currentTemporarySite }; } } @@ -275,10 +288,12 @@ export function setTemporarySiteSpec( } // Then create a new temporary site - const defaultBlueprint = - 'https://raw.githubusercontent.com/WordPress/blueprints/refs/heads/trunk/blueprints/welcome/blueprint.json'; + const defaultBlueprint = DEFAULT_WELCOME_BLUEPRINT_URL; let resolvedBlueprint: ResolvedBlueprint | undefined = undefined; + let blueprintResolutionFailed: + | SetTemporarySiteSpecResult['blueprintResolutionFailed'] + | undefined; try { resolvedBlueprint = await resolveBlueprintFromURL( playgroundUrlWithQueryApiArgs, @@ -286,9 +301,28 @@ export function setTemporarySiteSpec( ); } catch (e) { logger.error( - 'Error resolving blueprint, fallink back to a blank blueprint.', + 'Error resolving blueprint, falling back to a blank blueprint.', e ); + const hasBlueprintQuery = + playgroundUrlWithQueryApiArgs.searchParams.has('blueprint-url'); + const hasFragment = + (playgroundUrlWithQueryApiArgs.hash || '').length > 1; + const attemptedBlueprintUrl = hasBlueprintQuery + ? playgroundUrlWithQueryApiArgs.searchParams.get( + 'blueprint-url' + )! + : !hasFragment + ? defaultBlueprint + : undefined; + const attemptedUrl = + attemptedBlueprintUrl || playgroundUrlWithQueryApiArgs.href; + blueprintResolutionFailed = buildBlueprintResolutionFailureDetails( + e, + attemptedUrl + ) || { + attemptedUrl, + }; // TODO: This is a hack – we are just abusing a URL-oriented // function to create a completely blank Blueprint. Let's fix this by // making default creation first-class. @@ -325,10 +359,81 @@ export function setTemporarySiteSpec( }; dispatch(sitesSlice.actions.addSite(newSiteInfo)); dispatch(sitesSlice.actions.setFirstTemporarySiteCreated()); - return newSiteInfo; + return { + site: newSiteInfo, + blueprintResolutionFailed, + }; }; } +function buildBlueprintResolutionFailureDetails( + error: unknown, + attemptedUrl?: string +): BlueprintResolutionFailure | undefined { + const details: BlueprintResolutionFailure = {}; + if (attemptedUrl) { + details.attemptedUrl = attemptedUrl; + } + + const errorLike = error as { + status?: unknown; + statusCode?: unknown; + statusText?: unknown; + response?: { status?: unknown; statusText?: unknown }; + cause?: { status?: unknown; statusText?: unknown }; + message?: unknown; + }; + + const statusCandidates = [ + errorLike?.status, + errorLike?.response?.status, + errorLike?.cause?.status, + errorLike?.statusCode, + ]; + for (const candidate of statusCandidates) { + if ( + typeof candidate === 'number' && + Number.isFinite(candidate) && + candidate > 0 + ) { + details.httpStatus = candidate; + break; + } + } + + const statusTextCandidates = [ + errorLike?.statusText, + errorLike?.response?.statusText, + errorLike?.cause?.statusText, + ]; + for (const candidate of statusTextCandidates) { + if (typeof candidate === 'string' && candidate.trim()) { + details.statusText = candidate.trim(); + break; + } + } + + let message: string | undefined; + if (typeof errorLike?.message === 'string') { + message = errorLike.message; + } else if (typeof error === 'string') { + message = error; + } + + if (message) { + const trimmedMessage = message.trim(); + if ( + trimmedMessage && + trimmedMessage !== details.statusText && + trimmedMessage !== attemptedUrl + ) { + details.message = trimmedMessage; + } + } + + return Object.keys(details).length ? details : undefined; +} + function parseSearchParams(searchParams: URLSearchParams) { const params: Record = {}; for (const key of searchParams.keys()) { diff --git a/packages/playground/website/src/lib/state/redux/slice-ui.ts b/packages/playground/website/src/lib/state/redux/slice-ui.ts index 672a3fe7ab..f597baacdf 100644 --- a/packages/playground/website/src/lib/state/redux/slice-ui.ts +++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts @@ -8,13 +8,26 @@ export type SiteError = | 'directory-handle-unknown-error' // @TODO: Improve name? | 'site-boot-failed' - | 'github-artifact-expired'; + | 'github-artifact-expired' + | 'blueprint-resolution-failed'; + +export interface BlueprintResolutionErrorContext { + attemptedUrl?: string; + httpStatus?: number; + statusText?: string; + message?: string; +} + +export interface ActiveSiteErrorContext { + blueprintResolution?: BlueprintResolutionErrorContext; +} export type SiteManagerSection = 'sidebar' | 'site-details' | 'blueprints'; export interface UIState { activeSite?: { slug: string; error?: SiteError; + context?: ActiveSiteErrorContext; }; activeModal: string | null; offline: boolean; @@ -68,9 +81,16 @@ const uiSlice = createSlice({ } : undefined; }, - setActiveSiteError: (state, action: PayloadAction) => { + setActiveSiteError: ( + state, + action: PayloadAction<{ + error: SiteError; + context?: ActiveSiteErrorContext; + }> + ) => { if (state.activeSite) { - state.activeSite.error = action.payload; + state.activeSite.error = action.payload.error; + state.activeSite.context = action.payload.context; } }, setActiveModal: (state, action: PayloadAction) => { diff --git a/packages/playground/website/src/lib/state/redux/store.ts b/packages/playground/website/src/lib/state/redux/store.ts index 3ba04ed195..94385b63ed 100644 --- a/packages/playground/website/src/lib/state/redux/store.ts +++ b/packages/playground/website/src/lib/state/redux/store.ts @@ -1,5 +1,5 @@ import { configureStore, createSelector } from '@reduxjs/toolkit'; -import type { SiteError } from './slice-ui'; +import type { ActiveSiteErrorContext, SiteError } from './slice-ui'; import uiReducer, { __internal_uiSlice, listenToOnlineOfflineEventsMiddleware, @@ -89,6 +89,11 @@ export const selectActiveSiteError = ( ): SiteError | undefined => state.ui.activeSite?.slug ? state.ui.activeSite.error : undefined; +export const selectActiveSiteErrorContext = ( + state: PlaygroundReduxState +): ActiveSiteErrorContext | undefined => + state.ui.activeSite?.slug ? state.ui.activeSite.context : undefined; + export const useActiveSite = () => useAppSelector(selectActiveSite); export const setActiveSite = (slug: string | undefined) => {