diff --git a/packages/playground/website/vite.config.ts b/packages/playground/website/vite.config.ts
index 94ed3fd3262..0d43fc6cb3f 100644
--- a/packages/playground/website/vite.config.ts
+++ b/packages/playground/website/vite.config.ts
@@ -1,6 +1,11 @@
///
import { defineConfig } from 'vite';
-import type { CommonServerOptions, Plugin, ViteDevServer } from 'vite';
+import type {
+ CommonServerOptions,
+ Plugin,
+ UserConfig,
+ ViteDevServer,
+} from 'vite';
import react from '@vitejs/plugin-react';
// eslint-disable-next-line @nx/enforce-module-boundaries
import { viteTsConfigPaths } from '../../vite-extensions/vite-ts-config-paths';
@@ -17,11 +22,12 @@ import {
} from '../build-config';
// eslint-disable-next-line @nx/enforce-module-boundaries
import { oAuthMiddleware } from './vite.oauth';
+import { serveClientLibrary } from './vite.serve-client-library';
import { exec as execCb } from 'node:child_process';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
import { copyFileSync, existsSync } from 'node:fs';
-import { join } from 'node:path';
+import { join, resolve } from 'node:path';
// eslint-disable-next-line @nx/enforce-module-boundaries
import { buildVersionPlugin } from '../../vite-extensions/vite-build-version';
// eslint-disable-next-line @nx/enforce-module-boundaries
@@ -72,7 +78,7 @@ export default defineConfig(({ command, mode }) => {
? 'https://wordpress-playground-cors-proxy.net/?'
: '/cors-proxy/?';
- return {
+ const config: UserConfig = {
root: __dirname,
// Split traffic from this server on dev so that the iframe content and
// outer content can be served from the same origin. In production it's
@@ -190,6 +196,7 @@ export default defineConfig(({ command, mode }) => {
server.middlewares.use(oAuthMiddleware);
},
},
+ serveClientLibrary(),
/**
* Copy the `.htaccess` file to the `dist` directory.
*/
@@ -383,4 +390,19 @@ export default defineConfig(({ command, mode }) => {
reporters: ['default'],
},
};
+
+ if (command === 'serve') {
+ config.resolve = {
+ ...(config.resolve ?? {}),
+ alias: {
+ ...(config.resolve?.alias ?? {}),
+ '/client/index.js': resolve(
+ __dirname,
+ '../client/src/index.ts'
+ ),
+ },
+ };
+ }
+
+ return config;
});
diff --git a/packages/playground/website/vite.serve-client-library.ts b/packages/playground/website/vite.serve-client-library.ts
new file mode 100644
index 00000000000..c030a9e17c8
--- /dev/null
+++ b/packages/playground/website/vite.serve-client-library.ts
@@ -0,0 +1,162 @@
+/**
+ * Vite plugin that serves the built @wp-playground/client library at
+ * /client/ during development, matching production where
+ * playground.wordpress.net/client/index.js is available.
+ *
+ * Auto-builds the client if missing and triggers a rebuild when source
+ * files change (detected via Vite's file watcher).
+ */
+import type { Plugin, ViteDevServer } from 'vite';
+import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
+import { join, resolve, relative, isAbsolute } from 'node:path';
+import { exec } from 'node:child_process';
+
+export function serveClientLibrary(): Plugin {
+ return {
+ name: 'serve-client-library',
+ configureServer(server: ViteDevServer) {
+ const repoRoot = join(__dirname, '../../../');
+ const clientDistDir = join(
+ repoRoot,
+ 'dist/packages/playground/client'
+ );
+ const clientSrcDir = join(__dirname, '../client/src');
+ let buildInProgress = false;
+ let stalenessChecked = false;
+ let sourcesDirty = false;
+
+ function newestMtimeIn(dir: string): number {
+ let newest = 0;
+ try {
+ for (const entry of readdirSync(dir, {
+ withFileTypes: true,
+ })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ newest = Math.max(newest, newestMtimeIn(full));
+ } else if (entry.isFile()) {
+ newest = Math.max(newest, statSync(full).mtimeMs);
+ }
+ }
+ } catch {
+ // Directory may not exist yet
+ }
+ return newest;
+ }
+
+ function triggerClientBuild() {
+ if (buildInProgress) {
+ return;
+ }
+ buildInProgress = true;
+ server.config.logger.warn(
+ '\n Building @wp-playground/client… Refresh when done.\n'
+ );
+ exec(
+ 'npx nx build playground-client',
+ { cwd: repoRoot },
+ (error, stdout, stderr) => {
+ buildInProgress = false;
+ stalenessChecked = true;
+ sourcesDirty = false;
+ if (error) {
+ server.config.logger.error(
+ ' @wp-playground/client build failed. ' +
+ 'Run manually: npx nx build playground-client\n'
+ );
+ if (stderr) {
+ server.config.logger.error(stderr);
+ }
+ } else {
+ server.config.logger.info(
+ ' @wp-playground/client built. Refresh to load.\n'
+ );
+ }
+ }
+ );
+ }
+
+ server.watcher.add(clientSrcDir);
+ server.watcher.on('all', (_event, changedPath) => {
+ const rel = relative(clientSrcDir, changedPath);
+ if (!rel.startsWith('..') && !isAbsolute(rel)) {
+ sourcesDirty = true;
+ stalenessChecked = false;
+ }
+ });
+
+ server.middlewares.use((req, res, next) => {
+ if (!req.url?.startsWith('/client/')) {
+ return next();
+ }
+
+ const distIndexPath = join(clientDistDir, 'index.js');
+
+ if (!existsSync(distIndexPath)) {
+ triggerClientBuild();
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Content-Type', 'application/javascript');
+ res.statusCode = 503;
+ res.end(
+ 'throw new Error(' +
+ '"@wp-playground/client is not built yet. ' +
+ 'A build was triggered automatically — refresh in a few seconds.\\n' +
+ 'Or build manually: npx nx build playground-client"' +
+ ');'
+ );
+ return;
+ }
+
+ if (!stalenessChecked && !buildInProgress) {
+ stalenessChecked = true;
+ const distMtime = statSync(distIndexPath).mtimeMs;
+ const srcMtime = newestMtimeIn(clientSrcDir);
+ if (srcMtime > distMtime) {
+ sourcesDirty = true;
+ }
+ }
+ if (sourcesDirty && !buildInProgress) {
+ sourcesDirty = false;
+ triggerClientBuild();
+ }
+
+ let urlPath: string;
+ try {
+ urlPath = new URL(req.url, 'http://localhost').pathname;
+ } catch {
+ res.statusCode = 400;
+ res.end('Invalid request URL');
+ return;
+ }
+ const filePath = resolve(
+ clientDistDir,
+ urlPath.slice('/client/'.length)
+ );
+ const rel = relative(clientDistDir, filePath);
+ if (rel.startsWith('..') || isAbsolute(rel)) {
+ res.statusCode = 403;
+ res.end();
+ return;
+ }
+ if (!existsSync(filePath) || statSync(filePath).isDirectory()) {
+ return next();
+ }
+ const contentTypes: Record = {
+ '.js': 'application/javascript',
+ '.cjs': 'application/javascript',
+ '.json': 'application/json',
+ '.map': 'application/json',
+ };
+ const ext = Object.keys(contentTypes).find((e) =>
+ filePath.endsWith(e)
+ );
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader(
+ 'Content-Type',
+ ext ? contentTypes[ext] : 'application/octet-stream'
+ );
+ res.end(readFileSync(filePath));
+ });
+ },
+ };
+}