如何在 Next.js 应用程序中添加 Monaco 并安全运行不受信任的用户代码
虽然许多人转向 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。
- 您可以在此处仪表板中找到或创建个人访问令牌
- 您的组织 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
路由将包含逻辑,用于在给定子托管组织的情况下创建新项目。
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
,因此我们需要轮询部署详细信息端点,直到状态变为 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 钩子
setURL
用于持久保存部署的 URLsetIsLoading
用于持久保存 iframe 的加载状态。
现在,当部署成功时,我们将 setURL
设置为从 responseData
中检索到的域,并将状态更新为“部署成功”。然后,将 src
设置为部署 URL 的 iframe 将出现在文本编辑器下方。
它是这样工作的
您也可以编辑和重新部署代码
瞧!现在你已经将 Monaco 集成到 Next.js 的浏览器 IDE 中,它还能安全地部署和运行代码。
接下来呢?
为了教育和简化,我们简化了这个教程,将 Monaco 编辑器集成到 Next.js 中,以创建浏览器 IDE。但是,我们仍然可以添加许多功能来改善用户体验。
- 用户可以查看、编辑和部署现有项目,而不是在页面刷新时创建新项目。
- 扩展 Monaco 编辑器以包含简单的文件系统,以实现更高级的代码编辑。
- 用户身份验证和数据存储,因此用户可以访问、编辑和部署来自先前会话的代码。
- 托管应用程序,以便任何人都可以访问互联网上的应用程序。
最后,如果您发现这有帮助,有更多问题,或者希望我们扩展本指南,请在 Twitter 或 Discord 上告诉我们。
🚨️ 想构建浏览器 IDE,但担心部署和执行不受信任代码的安全性?查看 Subhosting。