DEV Community

Cover image for The Technology Behind “Moyuk”: Create, Run and Share Tools with TypeScript on Your Browser
kohii
kohii

Posted on

The Technology Behind “Moyuk”: Create, Run and Share Tools with TypeScript on Your Browser

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

Configuration

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.

App editor

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";
Enter fullscreen mode Exit fullscreen mode

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.7https://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);
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

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).

You can explicitly allow network access

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);
  }

  ...
}
Enter fullscreen mode Exit fullscreen mode

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",
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Search for the function to be executed from the members exported 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:

Upvotes and comments on Product Hunt are also greatly appreciated and motivating 🙏

Resources

Moyuk onProduct Hunt:

Moyuk Beta - Create, run & share your custom tools effortlessly | Product Hunt

Moyuk makes building, running and sharing custom tools a breeze. Simply write a TypeScript function, and Moyuk turns it into a web app – ready to execute, manage, and share in the browser. No Moyuk-specific code is required.

favicon producthunt.com

Introduction to Moyuk:

Docs:
https://docs.moyukapp.com/

Top comments (0)