如何将 Monaco 添加到 Next.js 应用程序并安全地运行不受信任的用户代码
虽然许多人因为 SaaS 平台比本地软件更灵活而转向它们,但 SaaS 真正释放的价值在于允许用户创建自己的自定义工作流程,例如转换或丰富数据流,或构建定制的自动化,而无需维护生产基础设施。
许多 SaaS 平台已经提供用户驱动的定制化功能 — Slack 允许构建自定义模块化工作流程,Salesforce 允许根据销售和营销活动创建工作流程,Twilio 允许通过无服务器环境操作语音呼叫。这些平台通过为其用户提供一个浏览器内 IDE 来部署和运行代码,从而最大限度地减少创建这些工作流程的摩擦 — 无需维护另一块生产基础设施。
然而,向应用程序添加功能齐全的浏览器内编辑器可能具有挑战性,更不用说构建一个用于安全部署和执行 Web 上不受信任的用户代码的平台了。允许任何人部署和运行代码,可能会迎来试图访问其他部署和您自己的系统,以及利用您的资源进行比特币挖矿的恶意用户部署代码。
在这篇博文中,我们将介绍如何解决这些关键挑战,到最后,您将拥有一个简单的浏览器 IDE,可以在云端部署和运行代码。
按照以下步骤操作,或直接跳到源代码。
集成 Monaco 和 Next.js
如果您要将 Next.js 应用程序引入本教程,那就太好了。如果不是,您可以从头创建一个新的应用程序并继续学习。请注意,在本教程中,我们将使用 Next.js 版本 14。
让我们创建显示浏览器内编辑器的页面。在 app
文件夹下,我们将创建 ide
文件夹,并在其中创建一个 page.js
文件。
首先,我们将添加指令和必要的导入。我们将使用 @monaco-editor/react
,这是一个用于 Monaco 的简洁 React 组件。我们还将导入 useState
和 useEffect
钩子来帮助管理状态。
"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
,您应该会看到
太棒了 — 您已经在 Next.js 应用程序中成功运行了 Monaco 编辑器!虽然您可以编写代码,但您无法部署或执行它。让我们修复这个问题。
部署和执行不受信任的代码
部署和执行第三方代码是一个复杂的工程问题,需要按需配置多个沙箱,防止部署访问其他部署以及您自己的系统,并正确路由请求流量,以保持所有用户的性能。
虽然您可以自己构建,但在本教程中,我们将使用 Deno Deploy 子托管 API,它允许您以编程方式在云端部署和执行代码。它旨在安全地运行不受信任的代码 — 您无需担心单个部署尝试访问另一个部署或您内部系统的进程。
本节将在上面内容的基础上进行扩展,包括以下内容
- 创建一个免费的子托管帐户
- 获取子托管 API 令牌
- 创建 API 路由以部署代码
- 在
app/ide/page.js
中定义存根函数
获取子托管 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 交互,我们将使用 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);
}
有关子托管 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
的响应,因此我们需要轮询部署详细信息端点,直到状态更改为 failed
或 success
。
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 钩子
- 用于持久化部署 URL 的
setURL
- 用于持久化 iframe 加载状态的
setIsLoading
。
现在,当部署成功时,我们将 setURL
设置为从 responseData
检索到的域,并将状态更新为“成功部署”。然后,src
设置为部署 URL 的 iframe 将出现在文本编辑器下方。
这是它的实际效果
您还可以编辑和重新部署代码
瞧!现在您已经将 Monaco 集成到具有 Next.js 的浏览器 IDE 中,该 IDE 还可以安全地部署和运行代码。
下一步是什么?
为了教育和简洁起见,我们简化了本教程,以便在 Next.js 中集成 Monaco 编辑器以创建浏览器 IDE。但是,我们仍然可以添加许多功能来改善用户体验
- 用户可以查看、编辑和部署现有项目,而不是在每次页面刷新时都创建一个新项目
- 扩展 Monaco 编辑器以包含简单的文件系统,以进行更高级的代码编辑
- 用户身份验证和数据存储,以便用户可以访问、编辑和部署以前会话中的代码
- 托管应用程序,以便任何人都可以通过互联网访问它
最后,如果您觉得这有帮助,有更多问题,或希望我们扩展本指南,请在 Twitter 或 Discord 上告诉我们。
🚨️ 想要构建浏览器 IDE,但担心部署和执行不受信任的代码的安全性?查看子托管。