Skip to content

Commit 950a70a

Browse files
apeatlingclaude
andcommitted
[Client] Add addRemoteOrigin() runtime API for extra iframe origins
Embedders that host Playground from a custom origin (e.g. their own production CDN, or dev ports other than localhost:5400) currently need to either set ADDITIONAL_REMOTE_ORIGINS at build time inside this package, or patch the prebuilt tarball. Neither is available to downstream npm consumers, since the vite plugin in this package's vite.config.ts only fires during the package's own build. Adds `addRemoteOrigin(origin: string)` exported from `@wp-playground/client`. Embedders call it before the `startPlaygroundWeb()` invocation that should see the new origin; multiple calls accumulate. The origin must be canonical: an `http://` or `https://` scheme, a lowercase ASCII (or punycode) host, an optional non-default port, and nothing else — no userinfo, path, query, fragment, or trailing slash. The build-time and runtime paths now share a single `validateOrigin` helper (`src/validate-origin.ts`) so they cannot drift, and both reject non-`http(s)` schemes and userinfo (which the previous round-trip-only check accepted but `URL.origin` would silently strip). Runtime registrations live in their own module (`runtime-remote-origins.ts`) so the existing vite plugin's source-rewrite of `additional-remote-origins.ts` can't clobber them. `assertLikelyCompatibleRemoteOrigin` composes the validation list at call time instead of at module-load time, so registrations made before `startPlaygroundWeb` are honored. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d1e9319 commit 950a70a

5 files changed

Lines changed: 265 additions & 19 deletions

File tree

packages/playground/client/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import { ProgressTracker } from '@php-wasm/progress';
3737
import type { MountDescriptor, PlaygroundClient } from '@wp-playground/remote';
3838
import type { PathAlias } from '@php-wasm/universal';
3939
import { additionalRemoteOrigins } from './additional-remote-origins';
40+
import { getRuntimeRemoteOrigins } from './runtime-remote-origins';
41+
export { addRemoteOrigin } from './runtime-remote-origins';
4042
// eslint-disable-next-line @nx/enforce-module-boundaries
4143
import { remoteDevServerHost, remoteDevServerPort } from '../../build-config';
4244
import { BlueprintsV1Handler } from './blueprints-v1-handler';
@@ -183,7 +185,7 @@ function allowStorageAccessByUserActivation(iframe: HTMLIFrameElement) {
183185

184186
const officialRemoteOrigin = 'https://playground.wordpress.net';
185187
const devRemoteOrigin = `http://${remoteDevServerHost}:${remoteDevServerPort}`;
186-
const validRemoteOrigins = [
188+
const builtInRemoteOrigins = [
187189
officialRemoteOrigin,
188190
devRemoteOrigin,
189191
// An older origin that's still used by some plugins.
@@ -217,6 +219,10 @@ const remoteOrigin =
217219
*/
218220
function assertLikelyCompatibleRemoteOrigin(remoteHtmlUrl: string) {
219221
const url = new URL(remoteHtmlUrl, remoteOrigin);
222+
const validRemoteOrigins = [
223+
...builtInRemoteOrigins,
224+
...getRuntimeRemoteOrigins(),
225+
];
220226

221227
const validRemote =
222228
validRemoteOrigins.includes(url.origin) &&
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
describe('addRemoteOrigin', () => {
2+
let addRemoteOrigin: (origin: string) => void;
3+
let getRuntimeRemoteOrigins: () => readonly string[];
4+
5+
beforeEach(async () => {
6+
// Reset the module so each test starts with an empty registry.
7+
vi.resetModules();
8+
({ addRemoteOrigin, getRuntimeRemoteOrigins } =
9+
await import('./runtime-remote-origins'));
10+
});
11+
12+
describe('valid origins', () => {
13+
it('should accept a basic https origin', () => {
14+
addRemoteOrigin('https://example.com');
15+
expect(getRuntimeRemoteOrigins()).toEqual(['https://example.com']);
16+
});
17+
18+
it('should accept an http origin', () => {
19+
addRemoteOrigin('http://example.com');
20+
expect(getRuntimeRemoteOrigins()).toEqual(['http://example.com']);
21+
});
22+
23+
it('should accept an origin with a non-default port', () => {
24+
addRemoteOrigin('http://localhost:5401');
25+
expect(getRuntimeRemoteOrigins()).toEqual([
26+
'http://localhost:5401',
27+
]);
28+
});
29+
30+
it('should accept a bracketed IPv6 origin', () => {
31+
addRemoteOrigin('http://[::1]:8080');
32+
expect(getRuntimeRemoteOrigins()).toEqual(['http://[::1]:8080']);
33+
});
34+
35+
it('should accept a punycode host', () => {
36+
addRemoteOrigin('https://xn--exmple-cua.com');
37+
expect(getRuntimeRemoteOrigins()).toEqual([
38+
'https://xn--exmple-cua.com',
39+
]);
40+
});
41+
});
42+
43+
describe('invalid origins', () => {
44+
it('should reject an empty string', () => {
45+
expect(() => addRemoteOrigin('')).toThrow("Invalid origin: ''");
46+
});
47+
48+
it('should reject an unparseable string', () => {
49+
expect(() => addRemoteOrigin('not a url')).toThrow(
50+
"Invalid origin: 'not a url'"
51+
);
52+
});
53+
54+
it('should reject an origin with a path', () => {
55+
expect(() => addRemoteOrigin('https://example.com/path')).toThrow(
56+
"Invalid origin: 'https://example.com/path'"
57+
);
58+
});
59+
60+
it('should reject an origin with a trailing slash', () => {
61+
expect(() => addRemoteOrigin('https://example.com/')).toThrow(
62+
"Invalid origin: 'https://example.com/'"
63+
);
64+
});
65+
66+
it('should reject an origin with a query string', () => {
67+
expect(() => addRemoteOrigin('https://example.com?x=1')).toThrow(
68+
"Invalid origin: 'https://example.com?x=1'"
69+
);
70+
});
71+
72+
it('should reject an origin with a fragment', () => {
73+
expect(() => addRemoteOrigin('https://example.com#frag')).toThrow(
74+
"Invalid origin: 'https://example.com#frag'"
75+
);
76+
});
77+
78+
it("should reject an origin with the scheme's default port specified", () => {
79+
// `new URL('https://example.com:443').href === 'https://example.com/'`,
80+
// so the round-trip strict-equality check rejects the input.
81+
expect(() => addRemoteOrigin('https://example.com:443')).toThrow(
82+
"Invalid origin: 'https://example.com:443'"
83+
);
84+
});
85+
86+
it('should reject an origin with an uppercase scheme', () => {
87+
// The URL parser normalizes the scheme to lowercase, so the
88+
// round-trip strict-equality check rejects upper-case input.
89+
expect(() => addRemoteOrigin('HTTPS://example.com')).toThrow(
90+
"Invalid origin: 'HTTPS://example.com'"
91+
);
92+
});
93+
94+
it('should reject an origin with a non-ASCII host', () => {
95+
// IDN hosts must be supplied in punycode form; otherwise the
96+
// URL parser normalizes the host and the round-trip check fails.
97+
expect(() => addRemoteOrigin('https://exämple.com')).toThrow(
98+
"Invalid origin: 'https://exämple.com'"
99+
);
100+
});
101+
102+
it('should reject an origin with userinfo', () => {
103+
// `URL.origin` strips userinfo, so an origin with userinfo can
104+
// never match the iframe's `url.origin` at validation time.
105+
expect(() =>
106+
addRemoteOrigin('https://user:pass@example.com')
107+
).toThrow("Invalid origin: 'https://user:pass@example.com'");
108+
});
109+
110+
it('should reject a `ws://` origin', () => {
111+
expect(() => addRemoteOrigin('ws://example.com')).toThrow(
112+
"Invalid origin: 'ws://example.com'"
113+
);
114+
});
115+
116+
it('should reject a `file://` origin', () => {
117+
expect(() => addRemoteOrigin('file:///tmp/x')).toThrow(
118+
"Invalid origin: 'file:///tmp/x'"
119+
);
120+
});
121+
122+
it('should reject a `data:` URL', () => {
123+
expect(() => addRemoteOrigin('data:text/plain,hi')).toThrow(
124+
"Invalid origin: 'data:text/plain,hi'"
125+
);
126+
});
127+
});
128+
129+
describe('accumulation', () => {
130+
it('should accumulate across calls in insertion order', () => {
131+
addRemoteOrigin('https://a.example.com');
132+
addRemoteOrigin('https://b.example.com');
133+
addRemoteOrigin('https://c.example.com');
134+
expect(getRuntimeRemoteOrigins()).toEqual([
135+
'https://a.example.com',
136+
'https://b.example.com',
137+
'https://c.example.com',
138+
]);
139+
});
140+
141+
it('should not deduplicate when the same origin is added twice', () => {
142+
addRemoteOrigin('https://example.com');
143+
addRemoteOrigin('https://example.com');
144+
expect(getRuntimeRemoteOrigins()).toEqual([
145+
'https://example.com',
146+
'https://example.com',
147+
]);
148+
});
149+
150+
it('should not register an origin when validation throws', () => {
151+
expect(() => addRemoteOrigin('not a url')).toThrow();
152+
expect(getRuntimeRemoteOrigins()).toEqual([]);
153+
});
154+
155+
it('should preserve previously registered origins when a later call is rejected', () => {
156+
addRemoteOrigin('https://first.example.com');
157+
expect(() => addRemoteOrigin('not a url')).toThrow();
158+
expect(getRuntimeRemoteOrigins()).toEqual([
159+
'https://first.example.com',
160+
]);
161+
});
162+
});
163+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { validateOrigin } from './validate-origin';
2+
3+
// Origins added at runtime via `addRemoteOrigin`. Kept separate from the
4+
// build-time `additionalRemoteOrigins` array (see `additional-remote-origins.ts`)
5+
// because the vite plugin in this package's `vite.config.ts` rewrites the
6+
// source of that file when `ADDITIONAL_REMOTE_ORIGINS` is set, and would
7+
// otherwise clobber any runtime registrations colocated with it.
8+
const runtimeRemoteOrigins: string[] = [];
9+
10+
/**
11+
* Returns the origins registered at runtime via {@link addRemoteOrigin}.
12+
*
13+
* @internal — exposed for use by the assertion in `index.ts` and by tests.
14+
* Not part of the public package API.
15+
*/
16+
export function getRuntimeRemoteOrigins(): readonly string[] {
17+
return runtimeRemoteOrigins;
18+
}
19+
20+
/**
21+
* Register an additional origin that is a valid host for Playground's
22+
* `remote.html` iframe.
23+
*
24+
* Use this when embedding Playground from an origin that isn't part of
25+
* the built-in allowlist (`playground.wordpress.net`, `wasm.wordpress.net`,
26+
* the embedding page's own origin, or `localhost`/`127.0.0.1` on the
27+
* default dev port). Call this before the `startPlaygroundWeb()`
28+
* invocation that should see the new origin. Multiple calls accumulate:
29+
* each call appends an entry, and duplicate origins are preserved (they
30+
* are not deduplicated or treated as a no-op).
31+
*
32+
* The origin must be in canonical form: an `http://` or `https://` scheme,
33+
* a lowercase ASCII (or punycode) host, an optional non-default port, and
34+
* nothing else. No userinfo, path, query, fragment, or trailing slash.
35+
* Invalid input throws and the registration is rejected.
36+
*
37+
* @example
38+
* import { addRemoteOrigin, startPlaygroundWeb } from '@wp-playground/client';
39+
*
40+
* addRemoteOrigin('https://playground.example.com');
41+
* await startPlaygroundWeb({ ... });
42+
*/
43+
export function addRemoteOrigin(origin: string): void {
44+
validateOrigin(origin);
45+
runtimeRemoteOrigins.push(origin);
46+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Assert that a string is a canonical origin: an `http://` or `https://`
3+
* scheme followed by a host (with an optional non-default port) and
4+
* nothing else — no userinfo, no path, no query, no fragment.
5+
*
6+
* Used by both the build-time `ADDITIONAL_REMOTE_ORIGINS` env-var seam
7+
* (see `vite.config.ts`) and the runtime `addRemoteOrigin` API
8+
* (see `runtime-remote-origins.ts`) so the two paths stay in lockstep.
9+
*
10+
* @throws if the input is not a canonical origin. The thrown message
11+
* includes a short reason so build-time misconfigurations are easy to
12+
* diagnose from a CI log.
13+
*
14+
* @internal
15+
*/
16+
export function validateOrigin(origin: string): void {
17+
let url: URL;
18+
try {
19+
url = new URL(origin);
20+
} catch {
21+
throw invalidOriginError(origin, 'not a valid URL');
22+
}
23+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
24+
throw invalidOriginError(origin, 'must use http:// or https://');
25+
}
26+
if (url.username !== '' || url.password !== '') {
27+
throw invalidOriginError(
28+
origin,
29+
'must not include username or password'
30+
);
31+
}
32+
// Round-trip check rejects any input that the URL parser had to
33+
// normalize: paths, queries, fragments, trailing slashes, uppercase
34+
// schemes, and ports that match the scheme's default.
35+
if (url.href !== `${origin}/`) {
36+
throw invalidOriginError(
37+
origin,
38+
'must be a canonical http(s) origin with no path, query, fragment, trailing slash, or default port'
39+
);
40+
}
41+
}
42+
43+
function invalidOriginError(origin: string, reason: string): Error {
44+
return new Error(`Invalid origin: '${origin}' (${reason})`);
45+
}

packages/playground/client/vite.config.ts

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,7 @@ import { viteIgnoreImports } from '../../vite-extensions/vite-ignore-imports';
1010
import { buildVersionPlugin } from '../../vite-extensions/vite-build-version';
1111
// eslint-disable-next-line @nx/enforce-module-boundaries
1212
import viteGlobalExtensions from '../../vite-extensions/vite-global-extensions';
13-
14-
function validateOrigin(origin: string) {
15-
try {
16-
const url = new URL(origin);
17-
if (url.href === `${origin}/`) {
18-
return true;
19-
}
20-
} catch {
21-
// Let exceptions fall through to the error below
22-
}
23-
24-
throw new Error(`Invalid origin: '${origin}'`);
25-
}
13+
import { validateOrigin } from './src/validate-origin';
2614

2715
const additionalRemoteOriginsModulePath = join(
2816
__dirname,
@@ -67,11 +55,9 @@ export default defineConfig({
6755
return code;
6856
}
6957

70-
const additionalRemoteOrigins = process.env[
71-
'ADDITIONAL_REMOTE_ORIGINS'
72-
]
73-
.split(',')
74-
.filter(validateOrigin);
58+
const additionalRemoteOrigins =
59+
process.env['ADDITIONAL_REMOTE_ORIGINS'].split(',');
60+
additionalRemoteOrigins.forEach(validateOrigin);
7561
return `export const additionalRemoteOrigins = ${JSON.stringify(
7662
additionalRemoteOrigins
7763
)};`;

0 commit comments

Comments
 (0)