diff --git a/client/public/runner.html b/client/public/runner.html
deleted file mode 100644
index d3374bb6a622..000000000000
--- a/client/public/runner.html
+++ /dev/null
@@ -1,167 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/client/src/document/hooks.ts b/client/src/document/hooks.ts
index c75aa69515a5..84dc966696e1 100644
--- a/client/src/document/hooks.ts
+++ b/client/src/document/hooks.ts
@@ -51,6 +51,7 @@ export function useCollectSample(doc: any) {
export function useRunSample(doc: Doc | undefined) {
const isServer = useIsServer();
const locale = useLocale();
+ const { hash } = useLocation();
useEffect(() => {
if (isServer) {
@@ -61,18 +62,15 @@ export function useRunSample(doc: Doc | undefined) {
return;
}
document.querySelectorAll("iframe").forEach((iframe) => {
- const src = new URL(iframe.src || "", "https://example.com");
- if (!(src && src.pathname.toLowerCase().endsWith(`/runner.html`))) {
- return null;
- }
- const id = src.searchParams.get("id");
+ const id = iframe.getAttribute("data-live-id") || null;
+ const path = iframe.getAttribute("data-live-path") || "/";
if (!id) {
return null;
}
const r =
- getCodeAndNodesForIframeBySampleClass(id, src.pathname) ||
- getCodeAndNodesForIframe(id, iframe, src.pathname);
+ getCodeAndNodesForIframeBySampleClass(id, path) ||
+ getCodeAndNodesForIframe(id, iframe, path);
if (r === null) {
return null;
}
@@ -91,9 +89,10 @@ export function useRunSample(doc: Doc | undefined) {
code,
locale
);
- initPlayIframe(iframe, code);
+ const fullscreen = hash === `#livesample_fullscreen=${id}`;
+ initPlayIframe(iframe, code, fullscreen);
});
- }, [doc, isServer, locale]);
+ }, [doc, isServer, locale, hash]);
}
export function useDecorateCodeExamples(doc: Doc | undefined) {
diff --git a/client/src/playground/index.tsx b/client/src/playground/index.tsx
index 0bd14a54308a..08390366df95 100644
--- a/client/src/playground/index.tsx
+++ b/client/src/playground/index.tsx
@@ -11,7 +11,7 @@ import prettierPluginHTML from "prettier/plugins/html";
import { Button } from "../ui/atoms/button";
import Editor, { EditorHandle } from "./editor";
import { SidePlacement } from "../ui/organisms/placement";
-import { EditorContent, SESSION_KEY, updatePlayIframe } from "./utils";
+import { compressAndBase64Encode, EditorContent, SESSION_KEY } from "./utils";
import "./index.scss";
import { PLAYGROUND_BASE_HOST } from "../env";
@@ -107,6 +107,17 @@ export default function Playground() {
const iframe = useRef(null);
const diaRef = useRef(null);
+ const updateWithCode = async (code: EditorContent) => {
+ const { state } = await compressAndBase64Encode(JSON.stringify(code));
+ const sp = new URLSearchParams([["state", state]]);
+
+ if (iframe.current) {
+ const url = new URL(iframe.current.src);
+ url.search = sp.toString();
+ iframe.current.src = url.href;
+ }
+ };
+
useEffect(() => {
if (initialCode) {
store(SESSION_KEY, initialCode);
@@ -127,33 +138,28 @@ export default function Playground() {
return code;
}, [initialCode?.src]);
- let messageListener = useCallback(
- ({ data: { typ, prop, message } }) => {
- if (typ === "console") {
- if (prop === "clear") {
- setVConsole([]);
- } else if (
- (prop === "log" || prop === "error" || prop === "warn") &&
- typeof message === "string"
- ) {
- setVConsole((vConsole) => [...vConsole, { prop, message }]);
- } else {
- const warning = "[Playground] Unsupported console message";
- setVConsole((vConsole) => [
- ...vConsole,
- {
- prop: "warn",
- message: `${warning} (see browser console)`,
- },
- ]);
- console.warn(warning, { prop, message });
- }
- } else if (typ === "ready") {
- updatePlayIframe(iframe.current, getEditorContent());
+ let messageListener = useCallback(({ data: { typ, prop, message } }) => {
+ if (typ === "console") {
+ if (prop === "clear") {
+ setVConsole([]);
+ } else if (
+ (prop === "log" || prop === "error" || prop === "warn") &&
+ typeof message === "string"
+ ) {
+ setVConsole((vConsole) => [...vConsole, { prop, message }]);
+ } else {
+ const warning = "[Playground] Unsupported console message";
+ setVConsole((vConsole) => [
+ ...vConsole,
+ {
+ prop: "warn",
+ message: `${warning} (see browser console)`,
+ },
+ ]);
+ console.warn(warning, { prop, message });
}
- },
- [getEditorContent]
- );
+ }
+ }, []);
const setEditorContent = ({ html, css, js, src }: EditorContent) => {
htmlRef.current?.setContent(html);
@@ -169,6 +175,10 @@ export default function Playground() {
if (state === State.initial || state === State.remote) {
if (initialCode && Object.values(initialCode).some(Boolean)) {
setEditorContent(initialCode);
+ if (!gistId) {
+ // don't auto run shared code
+ updateWithCode(initialCode);
+ }
} else {
setEditorContent({
html: HTML_DEFAULT,
@@ -178,7 +188,7 @@ export default function Playground() {
}
setState(State.ready);
}
- }, [initialCode, state]);
+ }, [initialCode, state, gistId]);
useEffect(() => {
window.addEventListener("message", messageListener);
@@ -237,14 +247,7 @@ export default function Playground() {
iterations: 1,
};
document.getElementById("run")?.firstElementChild?.animate(loading, timing);
- iframe.current?.contentWindow?.postMessage(
- {
- typ: "reload",
- },
- {
- targetOrigin: "*",
- }
- );
+ updateWithCode({ html, css, js });
};
const format = async () => {
diff --git a/client/src/playground/utils.ts b/client/src/playground/utils.ts
index 172eeb1ea0b5..a35253b74cac 100644
--- a/client/src/playground/utils.ts
+++ b/client/src/playground/utils.ts
@@ -1,3 +1,5 @@
+import { PLAYGROUND_BASE_HOST } from "../env";
+
export const SESSION_KEY = "playground-session-code";
export interface EditorContent {
@@ -7,29 +9,6 @@ export interface EditorContent {
src?: string;
}
-export interface Message {
- typ: string;
- state: EditorContent;
-}
-
-export function updatePlayIframe(
- iframe: HTMLIFrameElement | null,
- editorContent: EditorContent | null
-) {
- if (!iframe || !editorContent) {
- return;
- }
-
- const message: Message = {
- typ: "init",
- state: editorContent,
- };
-
- iframe.contentWindow?.postMessage(message, {
- targetOrigin: "*",
- });
-}
-
export function codeToMarkdown(code: EditorContent): string {
const parts: string[] = [];
if (code.html) {
@@ -44,28 +23,58 @@ export function codeToMarkdown(code: EditorContent): string {
return parts.join("\n\n");
}
-export function initPlayIframe(
+export async function initPlayIframe(
iframe: HTMLIFrameElement | null,
- editorContent: EditorContent | null
+ editorContent: EditorContent | null,
+ fullscreen: boolean = false
) {
if (!iframe || !editorContent) {
return;
}
+ const { state, hash } = await compressAndBase64Encode(
+ JSON.stringify(editorContent)
+ );
+ const path = iframe.getAttribute("data-play-path");
+ const host = PLAYGROUND_BASE_HOST.startsWith("localhost")
+ ? PLAYGROUND_BASE_HOST
+ : `${hash}.${PLAYGROUND_BASE_HOST}`;
+ const url = new URL(
+ `${path || ""}${path?.endsWith("/") ? "" : "/"}runner.html`,
+ window.location.origin
+ );
+ url.host = host;
+ url.search = "";
+ url.searchParams.set("state", state);
+ iframe.src = url.href;
+ if (fullscreen) {
+ const urlWithoutHash = new URL(window.location.href);
+ urlWithoutHash.hash = "";
+ window.history.replaceState(null, "", urlWithoutHash);
+ window.location.href = url.href;
+ }
+}
+
+function bytesToBase64(bytes: ArrayBuffer) {
+ const binString = Array.from(new Uint8Array(bytes), (byte: number) =>
+ String.fromCodePoint(byte)
+ ).join("");
+ return btoa(binString);
+}
+
+export async function compressAndBase64Encode(inputString: string) {
+ const inputArray = new Blob([inputString]);
+
+ const compressionStream = new CompressionStream("deflate-raw");
+
+ const compressedStream = new Response(
+ inputArray.stream().pipeThrough(compressionStream)
+ ).arrayBuffer();
+
+ const compressed = await compressedStream;
+ const hashBuffer = await window.crypto.subtle.digest("SHA-256", compressed);
+ const hashArray = Array.from(new Uint8Array(hashBuffer)).slice(0, 20);
+ const hash = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
+ const state = bytesToBase64(compressed);
- const message: Message = {
- typ: "init",
- state: editorContent,
- };
- iframe.contentWindow?.postMessage?.(message, { targetOrigin: "*" });
- const deferred = ({ data: { typ = null, prop = {} } = {} } = {}) => {
- const id = new URL(iframe.src, "https://example.com").searchParams.get(
- "id"
- );
- if (id === prop["id"]) {
- if (typ === "ready") {
- iframe.contentWindow?.postMessage(message, { targetOrigin: "*" });
- }
- }
- };
- window.addEventListener("message", deferred);
+ return { state, hash };
}
diff --git a/cloud-function/src/app.ts b/cloud-function/src/app.ts
index 7cc61f1cd59c..c9cd727af7c5 100644
--- a/cloud-function/src/app.ts
+++ b/cloud-function/src/app.ts
@@ -21,10 +21,10 @@ import { redirectPreferredLocale } from "./middlewares/redirect-preferred-locale
import { redirectTrailingSlash } from "./middlewares/redirect-trailing-slash.js";
import { requireOrigin } from "./middlewares/require-origin.js";
import { notFound } from "./middlewares/not-found.js";
-import { resolveRunnerHtml } from "./middlewares/resolve-runner-html.js";
-import { proxyRunner } from "./handlers/proxy-runner.js";
import { stripForwardedHostHeaders } from "./middlewares/stripForwardedHostHeaders.js";
import { proxyPong } from "./handlers/proxy-pong.js";
+import { handleRunner } from "./internal/play/index.js";
+import { proxyContentAssets } from "./handlers/proxy-content-assets.js";
const router = Router();
router.use(cookieParser());
@@ -50,8 +50,7 @@ router.all("/pimg/*", requireOrigin(Origin.main), proxyPong);
router.get(
["/[^/]+/docs/*/runner.html", "/[^/]+/blog/*/runner.html", "/runner.html"],
requireOrigin(Origin.play),
- resolveRunnerHtml,
- proxyRunner
+ handleRunner
);
// Assets.
router.get(
@@ -79,7 +78,7 @@ router.get(
`/[^/]+/docs/*/*.(${ANY_ATTACHMENT_EXT.join("|")})`,
requireOrigin(Origin.main, Origin.liveSamples, Origin.play),
resolveIndexHTML,
- proxyContent
+ proxyContentAssets
);
// Pages.
router.use(redirectNonCanonicals);
diff --git a/cloud-function/src/handlers/proxy-content-assets.ts b/cloud-function/src/handlers/proxy-content-assets.ts
new file mode 100644
index 000000000000..fe88e3d3d912
--- /dev/null
+++ b/cloud-function/src/handlers/proxy-content-assets.ts
@@ -0,0 +1,50 @@
+/* eslint-disable n/no-unsupported-features/node-builtins */
+import {
+ createProxyMiddleware,
+ fixRequestBody,
+ responseInterceptor,
+} from "http-proxy-middleware";
+
+import { withContentResponseHeaders } from "../headers.js";
+import { Source, sourceUri } from "../env.js";
+import { PROXY_TIMEOUT } from "../constants.js";
+import { ACTIVE_LOCALES } from "../internal/constants/index.js";
+
+const target = sourceUri(Source.content);
+
+export const proxyContentAssets = createProxyMiddleware({
+ target,
+ changeOrigin: true,
+ autoRewrite: true,
+ proxyTimeout: PROXY_TIMEOUT,
+ xfwd: true,
+ selfHandleResponse: true,
+ on: {
+ proxyReq: fixRequestBody,
+ proxyRes: responseInterceptor(
+ async (responseBuffer, proxyRes, req, res) => {
+ withContentResponseHeaders(proxyRes, req, res);
+ const [, locale] = req.url?.split("/") || [];
+ if (
+ proxyRes.statusCode === 404 &&
+ locale &&
+ locale != "en-US" &&
+ ACTIVE_LOCALES.has(locale.toLowerCase())
+ ) {
+ const enUsAsset = await fetch(
+ `${target}${req.url?.slice(1).replace(locale, "en-US")}`
+ );
+ if (enUsAsset?.ok) {
+ res.statusCode = enUsAsset.status;
+ enUsAsset.headers.forEach((value, key) =>
+ res.setHeader(key, value)
+ );
+ return Buffer.from(await enUsAsset.arrayBuffer());
+ }
+ }
+
+ return responseBuffer;
+ }
+ ),
+ },
+});
diff --git a/cloud-function/src/handlers/proxy-runner.ts b/cloud-function/src/handlers/proxy-runner.ts
deleted file mode 100644
index 1f23dce1c79a..000000000000
--- a/cloud-function/src/handlers/proxy-runner.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import {
- createProxyMiddleware,
- fixRequestBody,
- responseInterceptor,
-} from "http-proxy-middleware";
-
-import { withRunnerResponseHeaders } from "../headers.js";
-import { Source, sourceUri } from "../env.js";
-import { PROXY_TIMEOUT } from "../constants.js";
-
-const target = sourceUri(Source.content);
-
-export const proxyRunner = createProxyMiddleware({
- target,
- changeOrigin: true,
- autoRewrite: true,
- proxyTimeout: PROXY_TIMEOUT,
- xfwd: true,
- selfHandleResponse: true,
- on: {
- proxyReq: fixRequestBody,
- proxyRes: responseInterceptor(
- async (responseBuffer, proxyRes, req, res) => {
- withRunnerResponseHeaders(proxyRes, req, res);
- return responseBuffer;
- }
- ),
- },
-});
diff --git a/cloud-function/src/headers.ts b/cloud-function/src/headers.ts
index e595fc448226..d61201c8f0ac 100644
--- a/cloud-function/src/headers.ts
+++ b/cloud-function/src/headers.ts
@@ -1,9 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http";
-import {
- CSP_VALUE,
- PLAYGROUND_UNSAFE_CSP_VALUE,
-} from "./internal/constants/index.js";
+import { CSP_VALUE } from "./internal/constants/index.js";
import { isLiveSampleURL } from "./utils.js";
import { ORIGIN_TRIAL_TOKEN } from "./env.js";
@@ -98,16 +95,3 @@ export function setContentResponseHeaders(
...(ORIGIN_TRIAL_TOKEN ? [["Origin-Trial", ORIGIN_TRIAL_TOKEN]] : []),
].forEach(([k, v]) => k && v && setHeader(k, v));
}
-
-export function withRunnerResponseHeaders(
- _proxyRes: IncomingMessage,
- _req: IncomingMessage,
- res: ServerResponse
-): void {
- [
- ["X-Content-Type-Options", "nosniff"],
- ["Clear-Site-Data", '"*"'],
- ["Strict-Transport-Security", "max-age=63072000"],
- ["Content-Security-Policy", PLAYGROUND_UNSAFE_CSP_VALUE],
- ].forEach(([k, v]) => k && v && res.setHeader(k, v));
-}
diff --git a/cloud-function/src/middlewares/resolve-runner-html.ts b/cloud-function/src/middlewares/resolve-runner-html.ts
deleted file mode 100644
index 7d2e71a17e9b..000000000000
--- a/cloud-function/src/middlewares/resolve-runner-html.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { NextFunction, Request, Response } from "express";
-
-export async function resolveRunnerHtml(
- req: Request,
- _res: Response,
- next: NextFunction
-) {
- const urlParsed = new URL(req.url, `${req.protocol}://${req.headers.host}`);
- if (urlParsed.pathname && urlParsed.pathname.endsWith("/runner.html")) {
- urlParsed.pathname = "/runner.html";
- req.url = urlParsed.toString();
- // Workaround for http-proxy-middleware v2 using `req.originalUrl`.
- // See: https://github.com/chimurai/http-proxy-middleware/pull/731
- req.originalUrl = req.url;
- }
- next();
-}
diff --git a/kumascript/macros/EmbedLiveSample.ejs b/kumascript/macros/EmbedLiveSample.ejs
index 8e9d2f91d298..6393dd934f0b 100644
--- a/kumascript/macros/EmbedLiveSample.ejs
+++ b/kumascript/macros/EmbedLiveSample.ejs
@@ -97,7 +97,7 @@ if (hasScreenshot) {
%> title="<%= title %> sample" id="frame_<%= id %>"<%
if (width) { %> width="<%= width %>"<% }
if (height) { %> height="<%= height %>"<% }
-%> src="<%- url %>"<%
+%> src="about:blank" data-live-path="<%=env.url%>" data-live-id="<%=id%>"<%
if (allowedFeatures) { %> allow="<%= allowedFeatures %>"<% }
if ($token) { %> data-token="<%= JSON.stringify($token) %>"<% }
%> sandbox="allow-same-origin allow-scripts"><%
diff --git a/kumascript/macros/LiveSampleLink.ejs b/kumascript/macros/LiveSampleLink.ejs
index 004ec58eb193..e231433ccbd0 100644
--- a/kumascript/macros/LiveSampleLink.ejs
+++ b/kumascript/macros/LiveSampleLink.ejs
@@ -11,4 +11,4 @@
//
// We pass env.url as $1 to LiveSampleURL to enforce fallback to prebuild samples.
%>
-<%=$1%>
+<%=$1%>
diff --git a/kumascript/tests/macros/EmbedLiveSample.test.ts b/kumascript/tests/macros/EmbedLiveSample.test.ts
index 3caf15b96b2a..df8456b90056 100644
--- a/kumascript/tests/macros/EmbedLiveSample.test.ts
+++ b/kumascript/tests/macros/EmbedLiveSample.test.ts
@@ -16,7 +16,7 @@ describeMacro("EmbedLiveSample", function () {
'"
);
@@ -29,7 +29,7 @@ describeMacro("EmbedLiveSample", function () {
'"
);
@@ -41,7 +41,7 @@ describeMacro("EmbedLiveSample", function () {
'"
);
@@ -54,7 +54,7 @@ describeMacro("EmbedLiveSample", function () {
'"
);
@@ -66,7 +66,7 @@ describeMacro("EmbedLiveSample", function () {
'"
);
@@ -79,7 +79,7 @@ describeMacro("EmbedLiveSample", function () {
' title="Example sample"' +
' id="frame_example"' +
' width="100%"' +
- ' src="https://live.mdnplay.dev/en-US/docs/Web/CSS/border-top-width/runner.html?id=example"' +
+ ' src="about:blank" data-live-path="/en-US/docs/Web/CSS/border-top-width" data-live-id="example"' +
' sandbox="allow-same-origin allow-scripts">' +
""
);
@@ -92,7 +92,7 @@ describeMacro("EmbedLiveSample", function () {
' title="Example sample"' +
' id="frame_example"' +
' width=""><script>alert("XSS");</script>"' +
- ' src="https://live.mdnplay.dev/en-US/docs/Web/CSS/border-top-width/runner.html?id=example"' +
+ ' src="about:blank" data-live-path="/en-US/docs/Web/CSS/border-top-width" data-live-id="example"' +
' sandbox="allow-same-origin allow-scripts">' +
""
);
@@ -105,7 +105,7 @@ describeMacro("EmbedLiveSample", function () {
' title="Images sample"' +
' id="frame_images"' +
' width="100%" height="250"' +
- ' src="https://live.mdnplay.dev/en-US/docs/Web/HTML/Element/figure/runner.html?id=images"' +
+ ' src="about:blank" data-live-path="/en-US/docs/Web/HTML/Element/figure" data-live-id="images"' +
' sandbox="allow-same-origin allow-scripts">' +
""
);
@@ -119,7 +119,7 @@ describeMacro("EmbedLiveSample", function () {
' title="增加关键帧 sample"' +
' id="frame_增加关键帧"' +
' width="100%" height="250"' +
- ' src="https://live.mdnplay.dev/zh-CN/docs/Web/CSS/CSS_Animations/Using_CSS_animations/runner.html?id=%E5%A2%9E%E5%8A%A0%E5%85%B3%E9%94%AE%E5%B8%A7"' +
+ ' src="about:blank" data-live-path="/zh-CN/docs/Web/CSS/CSS_Animations/Using_CSS_animations" data-live-id="增加关键帧"' +
' sandbox="allow-same-origin allow-scripts">' +
""
);
@@ -136,7 +136,7 @@ describeMacro("EmbedLiveSample", function () {
' title="%E4%B8%80%E4%B8%AA%E6%A8%A1%E6%9D%BF%E9%AA%A8%E6%9E%B6 sample"' +
' id="frame_一个模板骨架"' +
' width="160" height="160"' +
- ' src="https://live.mdnplay.dev/zh-CN/docs/Web/API/Canvas_API/Tutorial/Basic_usage/runner.html?id=%E4%B8%80%E4%B8%AA%E6%A8%A1%E6%9D%BF%E9%AA%A8%E6%9E%B6"' +
+ ' src="about:blank" data-live-path="/zh-CN/docs/Web/API/Canvas_API/Tutorial/Basic_usage" data-live-id="一个模板骨架"' +
' sandbox="allow-same-origin allow-scripts">' +
""
);
@@ -149,7 +149,7 @@ describeMacro("EmbedLiveSample", function () {
' title="Images sample"' +
' id="frame_images"' +
' width="100%" height=""><script>alert("XSS");</script>"' +
- ' src="https://live.mdnplay.dev/en-US/docs/Web/HTML/Element/figure/runner.html?id=images"' +
+ ' src="about:blank" data-live-path="/en-US/docs/Web/HTML/Element/figure" data-live-id="images"' +
' sandbox="allow-same-origin allow-scripts">' +
""
);
@@ -159,7 +159,7 @@ describeMacro("EmbedLiveSample", function () {
' title="Examples sample"' +
' id="frame_examples"' +
' width="700px" height="700px"' +
- ' src="https://live.mdnplay.dev/en-US/docs/Web/CSS/flex-wrap/runner.html?id=examples"' +
+ ' src="about:blank" data-live-path="/en-US/docs/Web/CSS/flex-wrap" data-live-id="examples"' +
' sandbox="allow-same-origin allow-scripts">' +
"";
itMacro("Three arguments: ID, width, height (same slug)", function (macro) {
diff --git a/libs/constants/index.js b/libs/constants/index.js
index b979102cebf1..8b0da8d3aea8 100644
--- a/libs/constants/index.js
+++ b/libs/constants/index.js
@@ -132,6 +132,7 @@ export const CSP_DIRECTIVES = {
"live-samples.mdn.allizom.net",
"*.mdnplay.dev",
"*.mdnyalp.dev",
+ "*.play.test.mdn.allizom.net",
"https://v2.scrimba.com",
"https://scrimba.com",
@@ -190,31 +191,6 @@ export const cspToString = (csp) =>
export const CSP_VALUE = cspToString(CSP_DIRECTIVES);
-const PLAYGROUND_UNSAFE_CSP_SCRIPT_SRC_VALUES = [
- "'self'",
- "https:",
- "'unsafe-eval'",
- "'unsafe-inline'",
- "'wasm-unsafe-eval'",
-];
-
-export const PLAYGROUND_UNSAFE_CSP_VALUE = cspToString({
- "default-src": ["'self'", "https:"],
- "script-src": PLAYGROUND_UNSAFE_CSP_SCRIPT_SRC_VALUES,
- "script-src-elem": PLAYGROUND_UNSAFE_CSP_SCRIPT_SRC_VALUES,
- "style-src": [
- "'report-sample'",
- "'self'",
- "https:",
- "'unsafe-inline'",
- "'unsafe-eval'",
- ],
- "img-src": ["'self'", "blob:", "https:", "data:"],
- "base-uri": ["'self'"],
- "worker-src": ["'self'"],
- "manifest-src": ["'self'"],
-});
-
// Always update client/src/setupProxy.js when adding/removing extensions, or it won't work on the dev server!
export const AUDIO_EXT = ["mp3", "ogg"];
export const FONT_EXT = ["woff2"];
diff --git a/libs/play/index.d.ts b/libs/play/index.d.ts
new file mode 100644
index 000000000000..66857d6c9178
--- /dev/null
+++ b/libs/play/index.d.ts
@@ -0,0 +1,62 @@
+/** @import { IncomingMessage, ServerResponse } from "http" */
+/** @import * as express from "express" */
+/**
+ * @typedef State
+ * @property {string} html
+ * @property {string} css
+ * @property {string} js
+ * @property {string} [src]
+ */
+/**
+ * @param {ServerResponse} res
+ */
+export function withRunnerResponseHeaders(
+ res: ServerResponse
+): void;
+/**
+ * @param {State | null} state
+ * @param {string} hrefWithCode
+ * @param {string} searchWithState
+ */
+export function renderWarning(
+ state: State | null,
+ hrefWithCode: string,
+ searchWithState: string
+): string;
+/**
+ * @param {State | null} [state=null]
+ */
+export function renderHtml(state?: State | null | undefined): string;
+/**
+ * @param {string | null} base64String
+ */
+export function decompressFromBase64(base64String: string | null): Promise<
+ | {
+ state: null;
+ hash: null;
+ }
+ | {
+ state: string;
+ hash: string;
+ }
+>;
+/**
+ * @param {express.Request} req
+ * @param {express.Response} res
+ */
+export function handleRunner(
+ req: express.Request,
+ res: express.Response
+): Promise>>;
+export const ORIGIN_PLAY: string;
+export const ORIGIN_MAIN: string;
+export const PLAYGROUND_UNSAFE_CSP_VALUE: string;
+export type State = {
+ html: string;
+ css: string;
+ js: string;
+ src?: string | undefined;
+};
+import type { IncomingMessage } from "http";
+import type { ServerResponse } from "http";
+import type * as express from "express";
diff --git a/libs/play/index.js b/libs/play/index.js
new file mode 100644
index 000000000000..fe9e7976042d
--- /dev/null
+++ b/libs/play/index.js
@@ -0,0 +1,355 @@
+import * as crypto from "node:crypto";
+
+import he from "he";
+
+export const ORIGIN_PLAY = process.env["ORIGIN_PLAY"] || "localhost";
+export const ORIGIN_MAIN = process.env["ORIGIN_MAIN"] || "localhost";
+
+/** @import { IncomingMessage, ServerResponse } from "http" */
+/** @import * as express from "express" */
+
+/**
+ * @typedef State
+ * @property {string} html
+ * @property {string} css
+ * @property {string} js
+ * @property {string} [src]
+ */
+
+/**
+ * @param {ServerResponse} res
+ */
+export function withRunnerResponseHeaders(res) {
+ [
+ ["X-Content-Type-Options", "nosniff"],
+ ["Clear-Site-Data", '"cache", "cookies", "storage"'],
+ ["Strict-Transport-Security", "max-age=63072000"],
+ ["Content-Security-Policy", PLAYGROUND_UNSAFE_CSP_VALUE],
+ ].forEach(([k, v]) => k && v && res.setHeader(k, v));
+}
+
+/**
+ * @param {Record} csp
+ */
+function cspToString(csp) {
+ return Object.entries(csp)
+ .map(([directive, values]) => `${directive} ${values.join(" ")};`)
+ .join(" ");
+}
+
+const PLAYGROUND_UNSAFE_CSP_SCRIPT_SRC_VALUES = [
+ "'self'",
+ "https:",
+ "'unsafe-eval'",
+ "'unsafe-inline'",
+ "'wasm-unsafe-eval'",
+];
+
+export const PLAYGROUND_UNSAFE_CSP_VALUE = cspToString({
+ "default-src": ["'self'", "https:"],
+ "script-src": PLAYGROUND_UNSAFE_CSP_SCRIPT_SRC_VALUES,
+ "script-src-elem": PLAYGROUND_UNSAFE_CSP_SCRIPT_SRC_VALUES,
+ "style-src": [
+ "'report-sample'",
+ "'self'",
+ "https:",
+ "'unsafe-inline'",
+ "'unsafe-eval'",
+ ],
+ "img-src": ["'self'", "blob:", "https:", "data:"],
+ "base-uri": ["'self'"],
+ "worker-src": ["'self'"],
+ "manifest-src": ["'self'"],
+});
+
+/**
+ * @param {State | null} state
+ * @param {string} hrefWithCode
+ * @param {string} searchWithState
+ */
+export function renderWarning(state, hrefWithCode, searchWithState) {
+ const { css, html, js } = state || {
+ css: "",
+ html: "",
+ js: "",
+ };
+ return `
+
+
+
+
+
+
+
+
+
+ ⚠️
+ Caution: This is a demo page
+ You’re about to view a live demo generated using the MDN Playground. This demo may include custom code created by another user and is intended for testing and exploration only. If you’re uncertain about its contents or would prefer not to proceed, you can open the example in the MDN Playground. Otherwise, feel free to continue and explore the example provided.
+
+ view code
+
+ html
+ ${he.encode(html.trim())}
+
+ css
+ ${he.encode(css.trim())}
+
+ js
+ ${he.encode(js.trim())}
+
+ Open in Playground
+ Continue
+
+
+`;
+}
+
+/**
+ * @param {State | null} [state=null]
+ */
+export function renderHtml(state = null) {
+ const { css, html, js } = state || {
+ css: "",
+ html: "",
+ js: "",
+ };
+ return `
+
+
+
+
+
+
+
+
+
+
+ ${html}
+
+
+
+`;
+}
+
+/**
+ * @param {string | null} base64String
+ */
+export async function decompressFromBase64(base64String) {
+ if (!base64String) {
+ return { state: null, hash: null };
+ }
+ const bytes = Buffer.from(base64String, "base64");
+ const hashBuffer = await crypto.subtle.digest("SHA-256", bytes);
+ const hashArray = Array.from(new Uint8Array(hashBuffer)).slice(0, 20);
+ const hash = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
+
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins
+ const decompressionStream = new DecompressionStream("deflate-raw");
+
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins
+ const decompressedStream = new Response(
+ new Blob([bytes]).stream().pipeThrough(decompressionStream)
+ ).arrayBuffer();
+
+ const state = new TextDecoder().decode(await decompressedStream);
+ return { state, hash };
+}
+
+const ORIGIN_PLAY_SUFFIX = `.${ORIGIN_PLAY}`;
+
+/**
+ *
+ * @param {string} hostname
+ */
+function playSubdomain(hostname) {
+ if (hostname.endsWith(ORIGIN_PLAY_SUFFIX)) {
+ return hostname.slice(0, -1 * ORIGIN_PLAY_SUFFIX.length);
+ }
+ return "";
+}
+
+/**
+ * @param {express.Request} req
+ * @param {express.Response} res
+ */
+export async function handleRunner(req, res) {
+ const url = new URL(req.url, "https://example.com");
+ const referer = new URL(
+ req.headers["referer"] || "https://example.com",
+ "https://example.com"
+ );
+ const stateParam = url.searchParams.get("state");
+ const { state, hash } = await decompressFromBase64(stateParam);
+
+ const isLocalhost = req.hostname === "localhost";
+ const hasMatchingHash = playSubdomain(req.hostname) === hash;
+ const isIframeOnMDN =
+ referer.hostname === ORIGIN_MAIN &&
+ req.headers["sec-fetch-dest"] === "iframe";
+
+ if (
+ !stateParam ||
+ !state ||
+ (!isLocalhost && !hasMatchingHash && !isIframeOnMDN)
+ ) {
+ return res.status(404).end();
+ }
+
+ const json = JSON.parse(state);
+ const codeParam = url.searchParams.get("code");
+ const codeCookie = req.cookies["code"];
+ if (req.headers["sec-fetch-dest"] === "iframe" || codeParam === codeCookie) {
+ const html = renderHtml(json);
+ withRunnerResponseHeaders(res);
+ return res.status(200).send(html);
+ } else {
+ const rand = crypto.randomUUID();
+ res.cookie("code", rand, {
+ expires: new Date(Date.now() + 60000),
+ httpOnly: true,
+ sameSite: "strict",
+ secure: true,
+ });
+ const urlWithCode = new URL(url);
+ urlWithCode.search = "";
+ urlWithCode.searchParams.set("state", stateParam);
+ urlWithCode.searchParams.set("code", rand);
+ return res
+ .status(200)
+ .send(
+ renderWarning(
+ json,
+ `${urlWithCode.pathname}${urlWithCode.search}`,
+ url.search
+ )
+ );
+ }
+}
diff --git a/libs/play/package-lock.json b/libs/play/package-lock.json
new file mode 100644
index 000000000000..6d5e73b99cc3
--- /dev/null
+++ b/libs/play/package-lock.json
@@ -0,0 +1,38 @@
+{
+ "name": "@yari-internal/play",
+ "version": "0.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@yari-internal/play",
+ "version": "0.0.1",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "he": "^1.2.0"
+ },
+ "devDependencies": {
+ "@types/he": "^1.2.3"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@types/he": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz",
+ "integrity": "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ }
+ }
+}
diff --git a/libs/play/package.json b/libs/play/package.json
new file mode 100644
index 000000000000..d8dc859e9d28
--- /dev/null
+++ b/libs/play/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@yari-internal/play",
+ "version": "0.0.1",
+ "private": true,
+ "license": "MPL-2.0",
+ "type": "module",
+ "main": "index.js",
+ "types": "index.js",
+ "dependencies": {
+ "he": "^1.2.0"
+ },
+ "devDependencies": {
+ "@types/he": "^1.2.3"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "export": "index.js"
+}
diff --git a/libs/play/tsconfig.json b/libs/play/tsconfig.json
new file mode 100644
index 000000000000..bfc947488a8c
--- /dev/null
+++ b/libs/play/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "include": ["*.js"],
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "strict": true,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "esModuleInterop": true
+ }
+}
diff --git a/server/index.ts b/server/index.ts
index 2dc964aea3b9..f2e1535b9e2c 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -23,7 +23,6 @@ import {
ANY_ATTACHMENT_EXT,
CSP_VALUE,
DEFAULT_LOCALE,
- PLAYGROUND_UNSAFE_CSP_VALUE,
} from "../libs/constants/index.js";
import {
STATIC_ROOT,
@@ -35,6 +34,7 @@ import {
BLOG_ROOT,
CURRICULUM_ROOT,
} from "../libs/env/index.js";
+import { PLAYGROUND_UNSAFE_CSP_VALUE } from "../libs/play/index.js";
import documentRouter from "./document.js";
import fakeV1APIRouter from "./fake-v1-api.js";
@@ -53,6 +53,7 @@ import {
findPostPathBySlug,
} from "../build/blog.js";
import { findCurriculumPageBySlug } from "../build/curriculum.js";
+import { handleRunner } from "../libs/play/index.js";
async function buildDocumentFromURL(url: string) {
try {
@@ -173,8 +174,6 @@ app.use("/api/*", proxy);
// This is an exception and it's only ever relevant in development.
app.use("/users/*", proxy);
-// The proxy middleware has to come before all other middleware to avoid modifying the requests we proxy.
-
app.use(express.json());
// Needed because we read cookies in the code that mimics what we do in Lambda@Edge.
@@ -182,8 +181,6 @@ app.use(cookieParser());
app.use(originRequestMiddleware);
-app.use(staticMiddlewares);
-
app.use(express.urlencoded({ extended: true }));
app.post(
@@ -279,11 +276,8 @@ app.get("/*/contributors.txt", async (req, res) => {
}
});
-app.get("/*/runner.html", (_, res) => {
- return res
- .setHeader("Content-Security-Policy", PLAYGROUND_UNSAFE_CSP_VALUE)
- .status(200)
- .sendFile(path.join(STATIC_ROOT, "runner.html"));
+app.get(["/*/runner.html", "/runner.html"], (req, res) => {
+ handleRunner(req, res);
});
if (CURRICULUM_ROOT) {
@@ -425,6 +419,9 @@ if (contentProxy) {
}
});
}
+
+app.use(staticMiddlewares);
+
app.get("/*", (_, res) => send404(res));
if (!fs.existsSync(path.resolve(CONTENT_ROOT))) {
diff --git a/server/middlewares.ts b/server/middlewares.ts
index 0cae1c942f14..c0e3ff8bd5eb 100644
--- a/server/middlewares.ts
+++ b/server/middlewares.ts
@@ -1,9 +1,7 @@
import express from "express";
-import {
- CSP_VALUE,
- PLAYGROUND_UNSAFE_CSP_VALUE,
-} from "../libs/constants/index.js";
+import { CSP_VALUE } from "../libs/constants/index.js";
+import { PLAYGROUND_UNSAFE_CSP_VALUE } from "../libs/play/index.js";
import { STATIC_ROOT } from "../libs/env/index.js";
import { resolveFundamental } from "../libs/fundamental-redirects/index.js";
import { getLocale } from "../libs/locale-utils/index.js";
diff --git a/server/search-index.ts b/server/search-index.ts
index 805015eecf7b..3c31eb137614 100644
--- a/server/search-index.ts
+++ b/server/search-index.ts
@@ -36,9 +36,7 @@ function populateSearchIndex(searchIndex, localeLC) {
}
export async function searchIndexRoute(req, res) {
- // Remember, this is always in lowercase because of a middleware
- // that lowercases all incoming requests' pathname.
- const locale = req.params.locale;
+ const locale = req.params.locale.toLowerCase();
if (locale !== "en-us" && !CONTENT_TRANSLATED_ROOT) {
res.status(500).send("CONTENT_TRANSLATE_ROOT not set\n");
return;
diff --git a/testing/tests/index.test.ts b/testing/tests/index.test.ts
index dfca7fe24d0d..e23c9e368024 100644
--- a/testing/tests/index.test.ts
+++ b/testing/tests/index.test.ts
@@ -1392,23 +1392,6 @@ test("/Web/Embeddable should have 4 valid live samples", () => {
const html = fs.readFileSync(htmlFile, "utf-8");
const $ = cheerio.load(html);
expect($("iframe")).toHaveLength(4);
-
- const jsonFile = path.join(builtFolder, "index.json");
- const { doc } = JSON.parse(fs.readFileSync(jsonFile, "utf-8")) as {
- doc: Doc;
- };
- expect(doc.flaws.macros[0].name).toEqual("MacroDeprecatedError");
-
- // Only the transplanted live sample has a file.
- const builtFiles = fs.readdirSync(path.join(builtFolder, "legacy"));
- expect(
- builtFiles
- .filter((f) => f.includes("_sample_."))
- .map((f) => {
- const startOffset = "_sample_.".length;
- return f.substr(startOffset, f.length - startOffset - ".html".length);
- })
- ).toEqual(expect.arrayContaining(["foo"]));
});
test("headings with HTML should be rendered as HTML", () => {