The Technology Behind “Moyuk”: Create, Run and Share Tools with TypeScript on Your Browser
kohii
Posted on April 10, 2023
After over a year of development, I launched my personal project, Moyuk, on Product Hunt. In this post, I'll share some technical insights and knowledges about the service.
What I Created
Moyuk
You can check out the Product Hunt launch here 🚀:
Brief Overview
Simplest way to create and share your custom tools
In short, Moyuk is a platform that turns functions written in TypeScript into web applications (or "Apps") that can be executed, managed, and shared directly from your browser.
Please check out the link below for more information:
Overview of Technical Elements
In addition to the common application components (such as data input/output and UI rendering), Moyuk has several distinctive features:
- Components that analyze and transpile user-written TypeScript
- A sandboxed JavaScript execution environment for running transpiled code
These elements are all written in TypeScript and organized as a monorepo.
Main Technology Stack
- Application
- Infrastructure
- Vercel
- Supabase (DB, authentication, storage)
- Cloudflare (Workers, Pages)
- Other
- Docusaurus (Documentation)
- Turborepo (Monorepo)
Configuration
- The core application is built on Next.js
- Deployed on Vercel, with Supabase used for DB, authentication, and storage
- A sandboxed JavaScript execution environment runs within the browser to execute user-written code
- Content for the sandbox is served from Cloudflare
Moyuk as a General Application
Moyuk is built on Next.js and deployed on Vercel.
Features Directory
One of the distinctive aspects of the project is the adoption of the features directory, inspired by Bulletproof React. I personally prefer organizing components with high functional cohesion close together, which is why I chose this structure. I try not to write too much logic inside the pages
directory of Next.js and instead just combine and use components exported from features
.
State Management
After considering various options, I decided not to use a library for global state management.
Initially, I used zustand as a lightweight state management library, but eventually replaced it with React's Context, which was deemed sufficient. This decision was based on a policy of minimizing global state, which has not caused much pain.
Generally, the React Query client manages data fetched from the server.
Other data states are handled within appropriate components or hooks.
Backend
The database is Supabase, with Prisma as the ORM, and tRPC for frontend-backend data exchange. tRPC endpoints are built on top of Next.js API Routes and deployed as Vercel Serverless Functions. Supabase is used as a managed PostgreSQL service.
Supabase is a BaaS "Firebase alternative," allowing direct data reads and writes from the frontend using a client library. Initially, I used this system but switched to tRPC + Prisma as it became difficult to handle complex configurations and store domain logic in stored procedures.
The combination of tRPC + Prisma offers a great developer experience.
UI
I'm using MUI as the component library. The richness of MUI components greatly helped in quickly building the UI.
Forms are built with the popular React Hook Form + Zod.
Page Rendering
Next.js offers various rendering methods, which are crucial for performance and Web Vitals scores.
Basic Policy
The policy for Moyuk is as follows:
- Static pages (e.g., landing pages) → SG
- Generated statically at build time and cached on the CDN for speed.
- Authenticated dynamic pages (e.g., dashboard) → CSR
- Data is fetched client-side.
- Public dynamic pages (e.g., App or profile) → ISR
- Generated server-side (& cached on CDN), with revalidation on background requests when content becomes outdated.
- When page data is updated, cache is explicitly purged using On-demand revalidation.
tRPC and Rendering
tRPC offers SSR features, which enables automatic SSR for all pages. Initially, I used it without thinking but later stopped due to performance issues. Instead, I began explicitly prefetching the required data using Server-Side Helpers.
Rendering Pages with User-Selectable Private/Public Settings
Users can choose to make their created App public or private. If all App pages are rendered with ISR without consideration, CDN caches for private App pages will be created. Simply marking private App pages as 404 (notFound: true
) will return a 404 to the App owner.
To resolve this, in Moyuk, getStaticProps
fetches the App data. If the App is public, it renders normally with the data. For private (or non-existent) Apps, it renders a page with empty content. When users access the page, the App data is fetched again client-side. If the viewer is the App owner, the page continues to render. If not, a Not Found page is displayed.
Generating an App from User-Written TypeScript
Below is the UI for the App editing screen. When you write code in TypeScript, a preview of the App is displayed on the right side.
There are mainly two routes through which the user-written TypeScript is processed, and the App preview is generated.
1. Extracting Type Information from TypeScript Functions
In Moyuk, a form is automatically generated from the type information of the export default
function.
The extraction of type information is done using the TypeScript Compiler API. It was quite challenging due to the lack of documentation and articles.
Helpful Resources
- TypeScript Deep Dive - This resource helped in understanding internal concepts like the Scanner, Checker, and Binder.
- Using the Compiler API - Sample code for specific use cases is provided, which helped in guessing the purpose of each API.
- TypeScript AST Viewer - This viewer was useful in guessing the internal concepts and behavior of the Compiler API.
Support for Deno-compatible import statements
In Moyuk, you can use Deno-compatible import statements.
import { format } from "https://deno.land/std@0.181.0/fmt/duration.ts";
import { encode } from "npm:js-base64@^3.7";
The TypeScript Compiler itself does not have a mechanism for automatically resolving remote modules, so compiling them as it will result in errors. I will explain how this is resolved.
https
First, to resolve imports starting with https://
, all import URLs in the code are extracted before calling the TS Compiler API, and the files they reference are downloaded in advance.
- Files referenced by the downloaded files are also downloaded in a cascading manner.
- If the reference destination supports the X-TypeScript-Type header on a CDN (e.g., esm.sh, Skypack), the corresponding
.d.ts
file is downloaded.
Next, the actual compiler is called, but with some modifications. The TS Compiler API has a component called CompilerHost
that resolves the reference file for a module name. This is customized to resolve downloaded files for URL-formatted module names.
npm
By using the npm:
prefix (npm: specifiers), you can import npm packages.
Moyuk uses a CDN called esm.sh to download npm packages. esm.sh builds and distributes npm packages in ES Module format, so you can use npm packages in web-standard JS runtimes like browsers.
In Moyuk, if there is an import statement starting with npm:
, it is converted to an esm.sh URL, and then treated in the same way as the aforementioned https case.
npm:js-base64@^3.7
→ https://esm.sh/js-base64@^3.7
2. Transpiling TypeScript to JavaScript
Since the TypeScript written by users is actually executed in the browser, it is transpiled to executable JavaScript. If an import statement references an external module, that module is bundled into a single JavaScript file along with the user-written TypeScript.
Internally, esbuild is used. Esbuild has a plugin system, and I created custom plugins to resolve import statements like npm:
and https://
.
By the way, during app editing and preview, these processes are executed within a Web worker. When you publish an app, the build is performed on the backend.
Executing User-Written Code Safely
Moyuk needs to actually execute the code written by users and obtain the results.
The famous method for dynamically executing code in JavaScript is eval, but it is not safe.
Considering the possibility that app creators may write malicious code, it is necessary to execute code in an isolated, safe environment.
Selecting a Sandbox Technology for the JS Runtime
After extensive research, it was difficult to decide because many options were not secure. The table below shows some promising technologies that were investigated.
Name | Client / Server | Description | Notes |
---|---|---|---|
QuickJS | Client | A lightweight JS engine developed by Fabrice Bellard. Can be run as WASM when combined with quickjs-emscripten. Figma plugins run on this. | Initially considered, but eventually abandoned because it was imperfect. |
WebContainers | Client | A browser-based Node.js developed by StackBlitz. More faithful reproduction of Node.js compared to Sandpack. | Not considered because it was discovered late in development. |
Sandpack | Client | A browser-based Node.js developed by CodeSandbox. Runs on more browsers compared to WebContainers. | Not considered because it was discovered late in development. |
Deno Deploy Subhosting | Server | A new Deno Deploy-related service. An environment prepared for safely executing user-input JS/TS on the Edge. | Discovered midway through development but not noticed until recently. Still in Private Beta, but a top candidate depending on pricing. |
ShadowRealm | Client | An API for creating separate Realms (similar to global scope). | Rejected due to many limitations and being in the proposal stage (Stage 3). |
Custom runtime | Server | Setting up a custom server and creating a sandbox using Deno / Node.js or similar. | Rejected due to cost and maintenance concerns. |
iframe sandbox | Client | An iframe with the sandbox attribute becomes sandboxed. | See below. |
In the end, a combination of iframe sandbox, web worker, and Content Security Policy (CSP) was adopted.
The iframe sandbox isolates the environment, CSP restricts contact with the outside world, and the web worker executes the code.
iframe
First, create the iframe dynamically. Add the sandbox attribute to the iframe and serve the content loaded into the iframe from a separate domain (different origin) from moyukapp.com. This prevents any access from inside the iframe to the outside. Note that CodeSandbox also uses an iframe, but Moyuk's settings are much stricter.
const iframe = document.createElement("iframe");
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin");
iframe.style.display = "none";
iframe.src = sandboxUrl;
document.body.appendChild(iframe);
Please note that it is dangerous to add allow-scripts
and allow-same-origin
to the sandbox
attribute and fetch iframe content from the same origin, as this will break the sandboxing.
Web worker and CSP
Next, start a Web worker inside the iframe. The JS loaded into the Web worker has the following Content-Security-Policy (CSP) specified in the response header:
Content-Security-Policy: default-src 'none'; script-src blob:
This prevents scripts loaded into the Web worker from executing network access, eval
, and other operations. Loading scripts from blob:
URLs to execute user-written code is allowed (explained later).
In Moyuk, network access can be explicitly allowed by the user (Cloudflare Workers are used to dynamically rewrite the CSP).
Loading user-written code
Load the user-written code (transpiled to JS) into the aforementioned Web worker.
Moyuk's main application passes the code to the iframe, which in turn passes it to the Web worker. The Web worker converts the received code into a blob:
URL and performs a dynamic import. The return value of the dynamic import allows you to obtain members exported from the user-written script.
class ExecutionContext {
async importSourceCode(code: string) {
const url = URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
this.exportedMembers = await import(url);
}
...
}
Scripts imported via blob:
URLs inherit the parent (Web worker)'s CSP, so they cannot perform external network access or execute eval
.
Note that in Firefox, dynamic import within a Web worker is not yet supported, making it an unsupported browser for Moyuk.
Executing user-written functions
Execute the target function with the values entered on the App's UI as arguments. The process looks something like this:
class ExecutionContext {
...
async callFunction(functionName: string, args: unknown[]) {
const f = this.exportedMembers[functionName];
if (f && typeof f === "function") {
const returnValue = f(...args);
const value = await Promise.resolve(returnValue);
return {
type: "SUCCESS",
value,
};
}
return {
type: "FUNCTION_NOT_FOUND",
};
}
}
Search for the function to be executed from the members export
ed from the code dynamically imported in the previous step, provide the arguments, and execute the function. If the result is a Promise
, await
and return the result.
Conclusion
Thank you for reading until the end!
As the content is quite partial, if you have any questions, please feel free to ask.
I'm also planning to write about personal development retrospectives and lessons learned at some point.
And please give Moyuk a try!
https://moyukapp.com/
I appreciate your feedback and am happy to answer any questions you may have. The following channels are available:
- Send a DM/reply to @moyukapp on Twitter
- Create an Issue in Moyuk's GitHub repository
- Send an email to hello@moyukapp.com
Upvotes and comments on Product Hunt are also greatly appreciated and motivating 🙏
Resources
Moyuk onProduct Hunt:
Introduction to Moyuk:
Posted on April 10, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
April 10, 2023