Skip to content

Commit 446ce59

Browse files
jacob-ebeybrookslybrand
authored andcommitted
feat: playground
1 parent 1a91e61 commit 446ce59

File tree

4 files changed

+353
-0
lines changed

4 files changed

+353
-0
lines changed
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import * as React from "react";
2+
import { type HeadersFunction } from "@remix-run/node";
3+
import { Await } from "@remix-run/react";
4+
import ManacoEditor from "@monaco-editor/react";
5+
import type * as wc from "@webcontainer/api";
6+
7+
export default function Playground() {
8+
return (
9+
<div className="flex flex-1 m-auto w-[90rem] max-w-full px-4 sm:px-6 lg:px-8">
10+
<div className="flex flex-1">
11+
<section className="flex flex-1">
12+
<style
13+
dangerouslySetInnerHTML={{
14+
__html: ".playground { height: 100%; }",
15+
}}
16+
/>
17+
<Editor />
18+
</section>
19+
<section className="flex-1">
20+
<Preview />
21+
</section>
22+
</div>
23+
</div>
24+
);
25+
}
26+
27+
export const headers: HeadersFunction = () => {
28+
const headers = new Headers();
29+
headers.set("Cross-Origin-Embedder-Policy", "require-corp");
30+
headers.set("Cross-Origin-Opener-Policy", "same-origin");
31+
return headers;
32+
};
33+
34+
function Preview() {
35+
const state = useWebContainer();
36+
const [mounted, setMounted] = React.useState(false);
37+
React.useEffect(() => {
38+
setMounted(true);
39+
}, []);
40+
41+
const loadingState = (
42+
<div className="w-full h-full flex items-center justify-center">
43+
{state?.status
44+
? state.status === "ready"
45+
? "waiting for server..."
46+
: `${state.status}...`
47+
: "booting..."}
48+
</div>
49+
);
50+
51+
console.log({ mounted, state });
52+
if (!mounted || !state?.urlPromise) {
53+
return loadingState;
54+
}
55+
56+
return (
57+
<React.Suspense fallback={loadingState}>
58+
<Await resolve={state.urlPromise}>
59+
{(url) => (
60+
<iframe className="bg-white w-full h-full border" src={url} />
61+
)}
62+
</Await>
63+
</React.Suspense>
64+
);
65+
}
66+
67+
function Editor() {
68+
const [localContainer, setLocalContainer] =
69+
React.useState<wc.WebContainer | null>(null);
70+
const [mounted, setMounted] = React.useState(false);
71+
React.useEffect(() => {
72+
setMounted(true);
73+
}, []);
74+
const state = useWebContainer();
75+
const containerOrPromise =
76+
state?.container ?? state?.containerPromise ?? null;
77+
78+
const loadingState = (
79+
<div className="w-full h-full flex items-center justify-center">
80+
{state?.status ? `${state.status}...` : "booting..."}
81+
</div>
82+
);
83+
84+
if (!mounted) {
85+
return loadingState;
86+
}
87+
88+
const editor = (
89+
<ManacoEditor
90+
key="playground-editor"
91+
className="playground"
92+
loading={loadingState}
93+
wrapperProps={{
94+
className: "flex-1",
95+
style: { height: "unset" },
96+
}}
97+
defaultLanguage="javascript"
98+
defaultValue={DEFAULT_ROUTE}
99+
theme="vs-dark"
100+
onChange={(value) => {
101+
if (!localContainer) return;
102+
localContainer.fs.writeFile(
103+
"/app/routes/_index.tsx",
104+
value || "",
105+
"utf8",
106+
);
107+
}}
108+
/>
109+
);
110+
return (
111+
<React.Suspense fallback={loadingState}>
112+
<Await resolve={containerOrPromise}>
113+
{(container) => {
114+
if (localContainer !== container) {
115+
setTimeout(() => {
116+
setLocalContainer(container);
117+
}, 0);
118+
}
119+
return editor;
120+
}}
121+
</Await>
122+
</React.Suspense>
123+
);
124+
}
125+
126+
interface WebContainerStore {
127+
state: {
128+
container?: wc.WebContainer;
129+
containerPromise?: Promise<wc.WebContainer>;
130+
urlPromise?: Promise<string>;
131+
status:
132+
| "idle"
133+
| "booting"
134+
| "initializing"
135+
| "installing"
136+
| "ready"
137+
| "error";
138+
};
139+
subscribe: (onStoreChange: () => void) => () => void;
140+
getSnapshot: () => (typeof webContainerStore)["state"];
141+
getServerSnapshot: () => (typeof webContainerStore)["state"];
142+
update: (newState: Partial<(typeof webContainerStore)["state"]>) => void;
143+
}
144+
145+
declare global {
146+
interface Window {
147+
webContainerStore?: WebContainerStore;
148+
}
149+
}
150+
151+
const onChangeHandlers = new Set<() => void>();
152+
let webContainerStore: WebContainerStore = {
153+
state: {
154+
status: "idle",
155+
},
156+
subscribe: (onChange) => {
157+
onChangeHandlers.add(onChange);
158+
return () => {
159+
onChangeHandlers.delete(onChange);
160+
};
161+
},
162+
getSnapshot: () => webContainerStore.state,
163+
getServerSnapshot: () => webContainerStore.state,
164+
update: (newState) => {
165+
webContainerStore.state = Object.assign(
166+
{},
167+
webContainerStore.state,
168+
newState,
169+
);
170+
onChangeHandlers.forEach((onChange) => onChange());
171+
},
172+
};
173+
if (typeof document !== "undefined") {
174+
if (window.webContainerStore) {
175+
webContainerStore = window.webContainerStore;
176+
} else {
177+
window.webContainerStore = webContainerStore;
178+
}
179+
}
180+
181+
function useWebContainer() {
182+
const store = React.useSyncExternalStore(
183+
webContainerStore.subscribe,
184+
webContainerStore.getSnapshot,
185+
webContainerStore.getServerSnapshot,
186+
);
187+
188+
if (typeof document === "undefined") {
189+
return null;
190+
}
191+
192+
if (!store.container && !store.containerPromise) {
193+
const deferredURL = new Deferred<string>();
194+
webContainerStore.update({
195+
status: "booting",
196+
urlPromise: deferredURL.promise,
197+
containerPromise: import("@webcontainer/api")
198+
.then(({ WebContainer }) => WebContainer.boot())
199+
.then(async (container) => {
200+
webContainerStore.update({ status: "initializing" });
201+
202+
// npx -y create-remix@latest . -y --no-color --no-motion --no-install --no-git-init
203+
const process = await container.spawn("npx", [
204+
"-y",
205+
"create-remix@latest",
206+
".",
207+
"-y",
208+
"--no-color",
209+
"--no-motion",
210+
"--no-install",
211+
"--no-git-init",
212+
]);
213+
if ((await process.exit) !== 0) {
214+
throw new Error("Failed to create remix app");
215+
}
216+
217+
container.fs.writeFile(
218+
"/app/routes/_index.tsx",
219+
DEFAULT_ROUTE,
220+
"utf8",
221+
);
222+
223+
return container;
224+
})
225+
.then(async (container) => {
226+
webContainerStore.update({ status: "installing" });
227+
const process = await container.spawn("npm", ["install"]);
228+
if ((await process.exit) !== 0) {
229+
throw new Error("Failed to install dependencies");
230+
}
231+
232+
return container;
233+
})
234+
.then((container) => {
235+
webContainerStore.update({
236+
status: "ready",
237+
container,
238+
containerPromise: undefined,
239+
});
240+
241+
container.on("server-ready", (port, url) => {
242+
if (port === 3000) {
243+
deferredURL.resolve(url);
244+
}
245+
});
246+
247+
container
248+
.spawn("npm", ["run", "dev"])
249+
.then(async (process) => {
250+
return process.exit;
251+
})
252+
.catch((reason) => {
253+
deferredURL.reject(reason);
254+
});
255+
256+
return container;
257+
})
258+
.catch((reason) => {
259+
deferredURL.reject(reason);
260+
throw reason;
261+
}),
262+
});
263+
}
264+
265+
return store;
266+
}
267+
268+
class Deferred<T> {
269+
promise: Promise<T>;
270+
resolve!: (value: T) => void;
271+
reject!: (reason?: any) => void;
272+
constructor() {
273+
this.promise = new Promise<T>((resolve, reject) => {
274+
this.resolve = resolve;
275+
this.reject = reject;
276+
});
277+
}
278+
}
279+
280+
const js = String.raw;
281+
const DEFAULT_ROUTE = js`
282+
import { Form, useActionData } from "@remix-run/react";
283+
284+
export async function action({ request }) {
285+
const formData = new URLSearchParams(await request.text());
286+
return {
287+
message: "Hello, " + (formData.get("name") || "World") + "!",
288+
};
289+
}
290+
291+
export default function Route() {
292+
const actionData = useActionData();
293+
294+
return (
295+
<main>
296+
<h1>Hello, World!</h1>
297+
<Form method="post">
298+
<label>
299+
What's your name?
300+
<input name="name" />
301+
</label>
302+
<button type="submit">Submit</button>
303+
</Form>
304+
{actionData && <p>{actionData.message}</p>}
305+
</main>
306+
);
307+
}
308+
`.trim();

app/ui/header.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export function Header({
4141
<HeaderLink to="/blog">Blog</HeaderLink>
4242
<HeaderLink to="/showcase">Showcase</HeaderLink>
4343
<HeaderLink to="/resources">Resources</HeaderLink>
44+
<HeaderLink to="/playground">Playground</HeaderLink>
4445
</nav>
4546

4647
<HeaderMenuMobile className="md:hidden" />

package-lock.json

Lines changed: 42 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)