跳到主要内容
Deno 2.4 携 deno bundle、字节/文本导入、稳定版 OTel 等功能正式发布
了解更多
Add Monaco to Next.js

如何将 Monaco 添加到 Next.js 应用并安全运行不受信任的用户代码

许多人青睐 SaaS 平台,因为它比本地部署软件更具灵活性,而 SaaS 真正释放的价值在于允许用户创建自己的自定义工作流,例如转换或丰富数据流,或者构建定制自动化,而无需维护生产基础设施。

许多 SaaS 平台已经提供了用户驱动的定制能力——Slack 支持构建自定义模块化工作流,Salesforce 允许根据销售和营销活动创建工作流,Twilio 则通过无服务器环境提供语音通话操作。这些平台通过向用户提供可在浏览器中部署和运行代码的 IDE,最大程度地减少了创建这些工作流的阻力——从而无需维护另一块生产基础设施。

然而,将功能齐全的浏览器内编辑器添加到应用程序中可能具有挑战性,更不用说构建一个用于在网络上安全部署和执行不受信任的用户代码的平台了。允许任何人部署和运行代码,可能会招致恶意用户部署试图访问*其他*部署和您自己系统的代码,以及利用您的资源进行比特币挖矿。

在这篇博客文章中,我们将探讨如何解决这些关键挑战,到最后,您将拥有一个简单的浏览器 IDE,可以在云端部署和运行代码。

本教程中的浏览器 IDE 演示。

请按照以下步骤操作,或直接跳到源代码

集成 Monaco 和 Next.js

如果您已有一个 Next.js 应用可用于本教程,那太好了。如果没有,您可以从头开始创建一个新的并跟随操作。请注意,本教程中我们将使用 Next.js 14 版本

让我们创建显示浏览器内编辑器的页面。在 app 文件夹下,我们将创建 ide 文件夹,并在其中创建一个 page.js 文件。

首先,我们将添加指令和必要的导入。我们将使用 @monaco-editor/react,这是一个用于 Monaco 的巧妙 React 组件。我们还将导入 useStateuseEffect 钩子来帮助管理状态。

"use client";

import Editor from "@monaco-editor/react";
import { useEffect, useState } from "react";

在此之下,我们将定义 IDE 组件

export default function IDE() {
  const handleSubmit = async () => {};

  return (
    <div className="flex justify-center items-start pt-10 h-screen">
      <div className="w-full max-w-4xl p-4 border">
        <form action="#" onSubmit={handleSubmit}>
          <div className="">
            <label htmlFor="comment" className="sr-only">
              Add your code
            </label>
            <Editor
              height="50vh"
              defaultLanguage="javascript"
              defaultValue='Deno.serve(req => new Response("Hello!"));'
            />
          </div>
          <div className="flex justify-between pt-2">
            <div className="flex items-center space-x-5"></div>
            <div className="flex-shrink-0">
              <button
                type="submit"
                className="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500"
              >
                Run
              </button>
            </div>
          </div>
        </form>
      </div>
    </div>
  );
}

请注意,我们目前只是定义了 handleSubmit 函数的空实现——我们稍后会完善它们。现在,如果您使用 npm run dev 启动服务器并将浏览器指向 localhost:3000/ide,您应该会看到

browser ide

太棒了——您的 Next.js 应用中已经成功运行了 Monaco 编辑器!虽然您可以编写代码,但还无法部署或执行它。让我们来解决这个问题。

部署和执行不受信任的代码

部署和执行第三方代码是一个复杂的工程问题,它需要按需配置多个沙箱,防止部署访问其他部署以及您自己的系统,并正确路由请求流量以维持所有用户的性能。

虽然您可以自己构建,但本教程中我们将使用 Deno Deploy Subhosting API,它允许您在云端以编程方式部署和执行代码。它旨在安全运行不受信任的代码——您无需担心单个部署会尝试访问其他部署或您内部系统的进程。

本节将在此基础上展开以下内容

获取 Subhosting API 令牌

在开始之前,您需要拥有一个 Deno Deploy 访问令牌以及您用于 Subhosting 的 Deno Deploy 组织的 ID。

  • 您可以在 此仪表盘中找到或创建个人访问令牌
  • 您的组织 ID 可以在 Deno Deploy 仪表盘页面顶部附近找到, 如此处描述

拥有这些信息后,创建一个 .env 文件,内容如下

DEPLOY_ACCESS_TOKEN=ddp_xxxxxxxxxxxxxxxxxxxxxxxxxx
DEPLOY_ORG_ID=536xxxxx-1111-1111-1111-111111111111

创建用于部署代码的 API 路由

让我们在 Next.js 应用中定义三个新的 API 路由

  • app/api/createproject/route.ts
  • app/api/createdeployment/route.ts
  • app/api/getdeployment/route.ts

接下来,我们的 createproject 路由将包含根据 Subhosting 组织创建新项目的逻辑。

import Subhosting from "subhosting";

const subhosting = new Subhosting();

export async function GET() {
  const orgId = process.env["DEPLOY_ORG_ID"];
  const project = await subhosting.organizations.projects.create(orgId, {
    name: null,
  });
  return Response.json(project);
}

请注意,我们显式传递了 name: null,这会告诉 Deno Deploy 生成一个全局唯一的项目名称。

我们的 createdeployment 路由将包含提交请求的逻辑。为了与 Subhosting API 交互,我们将使用 Subhosting npm 客户端库。使用 npm install --save subhosting 安装它,然后如下所示导入并使用它

import { NextRequest } from "next/server";
import Subhosting from "subhosting";

const subhosting = new Subhosting();

export async function POST(req: NextRequest) {
  const data = await req.json();
  const code = data["code"];
  const projectId = data["project"];

  const res = await subhosting.projects.deployments.create(projectId, {
    entryPointUrl: "main.ts",
    assets: {
      "main.ts": {
        kind: "file",
        content: code,
        encoding: "utf-8",
      },
    },
    envVars: {},
  });
  return Response.json(res);
}

有关 Subhosting API 的更多详细信息,请参阅我们的文档

最后,我们的 getdeployment 路由将检索部署的状态

import { NextRequest } from "next/server";
import Subhosting from "subhosting";

const subhosting = new Subhosting();

export async function POST(req: NextRequest) {
  const data = await req.json();
  const deploymentId = data["id"];
  const deployment = await subhosting.deployments.get(deploymentId);
  return Response.json(deployment);
}

由于创建部署的 API 将返回一个部署状态为 pending 的响应,因此我们需要轮询部署详情端点,直到状态变为 failedsuccess

连接 app/ide/page.js 到新创建的 API 路由

连接好 API 路由后,我们将填充主 IDE 页面所需的其余功能和状态管理。

在我们的 app/ide/page.js

/// Import statements from earlier.

export default function IDE() {
  const [project, setProject] = useState("");

  useEffect(() => {
    const createProject = async () => {
      try {
        const response = await fetch("/api/createproject", {
          method: "GET",
        });
                if (!response.ok) {
                    throw new Error(`Error: ${response.status}`);
                }
                const responseData = await response.json();
                setProject(responseData);
            } catch (error) {
                console.error("Failed to create project: ", error);
            }
        };

        // Every time the IDE is loaded, we'll create a new project.
        createProject();
    }, []);

    // Grab the project id from the state variable.
    const project_id = project["id"];

    // A simple sleep function to throttle our polling.
    const sleep = ms => new Promise(res => setTimeout(res, ms));

    // Poll deployment status.
    const pollDeploymentStatus = async (deploymentId) => {
        let response;
        try {
            response = await fetch("/api/getdeployment", {
                method: "POST",
                headers: {
                    "Content-Type": "application/json",
                },
                body: JSON.stringify({ id: deploymentId }),
            });
        } catch (error) {
            console.log(error);
        }
        return await response.json();
    };

    const handleSubmit = async (event) => {
        event.preventDefault();

        // Grab code from editor.
        const codeText = event.target.querySelector(".monaco-scrollable-element").textContent;

        try {
            const response = await fetch("/api/createdeployment", {
                method: "POST",
                headers: {
                    "Content-Type": "application/json",
                },
                body: JSON.stringify({ code: codeText, project: project_id }),
            });
            if (!response.ok) {
                throw new Error(`Error: ${response.status}`);
            }

            let responseData = await response.json();
            // Poll deployment details until status is no longer "pending".
      while (responseData["status"] === "pending") {
        await delay(3000);
        responseData = await pollDeploymentStatus(responseData["id"]);
      }
            
            // Show the deployment in an iFrame, which we'll show below.
        } catch (error) {
            console.log(error);
        }
    };

    return (
        // JSX from earlier.
    );
}

新代码的作用如下

  • 每当页面刷新或加载时,我们都会调用 createProject 并获取其 project_id
  • 当我们点击“运行”提交代码时,我们会从文本输入中获取代码,并创建一个部署。
  • 我们使用 deployment_id 轮询部署端点,直到其部署状态不再是 pending

部署已成功创建,但是,目前还没有任何显示。并且一个不显示已部署代码的浏览器 IDE 就像一个本地文本编辑器一样。所以接下来,我们将在 iframe 中添加成功部署。

显示和运行已部署的代码

我们将获取部署 URL,将其添加为 iframe,并在部署状态为 success 时显示它。

让我们更新 app/ide/page.js 以包含以下内容

  • 在 JSX 中添加一个 iframe
  • 添加逻辑以在部署状态为 success 且 iframe 未出错时显示 iframe
  • 添加显示和更新状态消息(“正在部署代码…”)的逻辑

这是更新后的 app/ide/page.js(为了简洁和可读性,省略了之前版本的代码)

/// ...

export default function IDE() {
  /// ...

  const [URL, setURL] = useState("");
  const [isLoading, setIsLoading] = useState(true);

  // Helper function to update status message.
  const updateStatus = (message) => {
    if (document.querySelector(".ide-message")) {
      document.querySelector(".ide-message").textContent = message;
    }
  };

  // We update this function to include `updateStatus`.
  const handleSubmit = async (event) => {
    event.preventDefault();

    updateStatus("Deploying code...");

    const codeText =
      event.target.querySelector(".monaco-scrollable-element").textContent;
    try {
      const response = await fetch("/api/createdeployment", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ code: codeText, project: project_id }),
      });

      if (!response.ok) {
        throw new Error(`Error: ${response.status}`);
      }

      // Poll deployment details until status is no longer pending.
      let responseData = await response.json();
      while (responseData["status"] === "pending") {
        await delay(3000);
        responseData = await pollDeploymentStatus(responseData["id"]);
      }

      // Define URL and show iframe.
      if (responseData["status"] === "success") {
        setURL(`http://${responseData.domains[0]}`);
        updateStatus("Successfully deployed.");
      } else {
        updateStatus("Deployment failed.");
        throw new Error("Deployment failed");
      }
    } catch (error) {
      console.log(error);
    }
  };

  const handleLoad = () => {
    setIsLoading(false);
  };

  const handleError = () => {
    setIsLoading(true);
  };

  return (
    <div className="flex justify-center items-start pt-10 h-screen">
      <div className="w-full max-w-4xl p-4 border">
        {/* form from before - ommitted for simplicity */}
        <div className="mt-4">
          {/* We'll use .ide-message to provide status updates to the user. */}
          <p className="ide-message mb-4"></p>
          {isLoading && (
            <p className="text-center">Deployed code will run here.</p>
          )}
          <iframe
            src={URL} // URL will be defined when deployment succeeds.
            title="Deployed Project"
            width="100%"
            height="300px"
            onLoad={handleLoad}
            onError={handleError}
            style={{ display: isLoading ? "none" : "block" }} // Hide iframe while loading
          >
          </iframe>
        </div>
      </div>
    </div>
  );
}

请注意,我们添加了两个新的 useState 钩子

  • setURL 用于持久化部署的 URL
  • setIsLoading 用于持久化 iframe 的加载状态。

现在,当部署成功时,我们将 setURL 设置为从 responseData 中检索到的域名,并将状态更新为“成功部署”。然后,src 设置为部署 URL 的 iframe 将出现在文本编辑器下方。

实际效果如下

您还可以编辑和重新部署代码

瞧!现在您已经将 Monaco 集成到使用 Next.js 构建的浏览器 IDE 中,它还可以安全地部署和运行代码。

下一步?

为了教学和简化,本教程对在 Next.js 中集成 Monaco 编辑器以创建浏览器 IDE 的内容进行了精简。然而,我们仍然可以添加许多功能来改善用户体验

  • 用户可以查看、编辑和部署现有项目,而不是在每次页面刷新时都创建一个新项目
  • 扩展 Monaco 编辑器以包含简单文件系统,用于更高级的代码编辑
  • 用户身份验证和数据存储,以便用户可以访问、编辑和部署先前会话中的代码
  • 托管应用,以便任何人都可以在互联网上访问它

最后,如果您觉得这有帮助,有更多问题,或者希望我们扩展本指南,请通过 TwitterDiscord 告诉我们。

🚨️ 想构建一个浏览器 IDE,但担心部署和执行不受信任代码的安全性问题? 查看 Subhosting