跳至主要内容
Deno 2 终于来了 🎉️
了解更多
Add Monaco to Next.js

如何在 Next.js 应用程序中添加 Monaco 并安全运行不受信任的用户代码

虽然许多人转向 SaaS 平台来获得比本地软件更高的灵活性,但 SaaS 真正的价值在于允许用户创建自己的自定义工作流程,例如转换或丰富数据流,或构建定制的自动化,而无需维护生产基础设施。

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

但是,向应用程序添加一个功能齐全的浏览器内编辑器可能很困难,更不用说构建一个平台来安全地部署和执行 Web 上不受信任的用户代码。允许任何人部署和运行代码会迎接潜在的恶意用户,他们可能会部署试图访问其他部署和您自己系统的代码,以及利用您的资源进行比特币挖矿。

在这篇博文中,我们将介绍如何解决这些关键挑战,最后,您将拥有一个简单的浏览器 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 子托管 API,它允许您以编程方式在云端部署和执行代码。 它旨在安全地运行不受信任的代码——您无需担心单个部署试图访问其他部署或您内部系统的进程。

本节将扩展上述内容,包括以下内容

获取子托管 API 令牌

在开始之前,您需要拥有 Deno Deploy 访问令牌和您用于子托管的 Deno Deploy 组织的 ID。

获得它们后,创建一个看起来像以下内容的 .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 路由将包含逻辑,用于在给定子托管组织的情况下创建新项目。

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 路由将包含逻辑以提交请求。为了与子托管 API 交互,我们将使用子托管 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);
}

有关子托管 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 中,它还能安全地部署和运行代码。

接下来呢?

为了教育和简化,我们简化了这个教程,将 Monaco 编辑器集成到 Next.js 中,以创建浏览器 IDE。但是,我们仍然可以添加许多功能来改善用户体验。

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

最后,如果您发现这有帮助,有更多问题,或者希望我们扩展本指南,请在 TwitterDiscord 上告诉我们。

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