使用 Subhosting API 构建您自己的云 IDE
越来越多的 SaaS 平台允许其用户通过代码自定义他们的产品,例如创建定制的工作流程或通过应用/集成市场。而实现代码级自定义且最大限度减少摩擦的一种流行方法是通过直接在其产品中嵌入云 IDE。
在本文中,我们将向您展示如何使用 Deno Subhosting API 构建您自己的云 IDE,该 API 允许您在数秒内在 Deno Deploy 的全球 v8 isolate 云上以编程方式部署和运行代码。我们将详细介绍我们的 Subhosting IDE Starter 模板,该模板构建于 Hono、Ace Editor 和 Deno 之上。
设置您的项目
在我们开始之前,我们需要以下事项
- 一个 Deno Deploy 账户
- 一个 Deno Deploy Subhosting 组织,您可以从您的 Deno Deploy 仪表板创建
在新的文件夹中创建以下项目结构
subhosting_starter_ide/
├── .env
├── App.tsx
├── deno.json
└── main.tsx
接下来,您必须创建以下环境变量
- 一个
DEPLOY_ACCESS_TOKEN
,您可以在您的 Deno Deploy 账户中生成 - 一个
DEPLOY_ORG_ID
,您可以从您的组织页面获取
获得这些值后,将它们添加到您的 .env
文件中
DEPLOY_ACCESS_TOKEN = ddp_xxxxxx;
DEPLOY_ORG_ID = ed63948c - xxx - xxx - xxx - xxxxxx;
让我们设置 deno.json
以包含运行服务器的命令和一个用于导入 Hono 的导入映射
{
"tasks": {
"dev": "deno run -A --watch --env main.tsx"
},
"imports": {
"$hono/": "https://deno.land/x/hono@v3.12.0/"
}
}
main.tsx
中构建服务器
在 我们云 IDE 的主要逻辑将在 main.tsx
中,它将创建服务器以及我们应用程序的以下路由
GET /
: 列出所有项目GET /deployments
: 列出给定项目的所有部署POST /deployment
: 为给定项目创建新的部署POST /project
: 为给定组织创建新的项目
我们需要能够从 ./static
文件夹提供静态资源,该文件夹包含客户端 JavaScript 和 CSS,例如 Ace.js
/** @jsx jsx */
import { Hono } from "$hono/mod.ts";
import { jsx } from "$hono/jsx/index.ts";
import { serveStatic } from "$hono/middleware.ts";
import App from "./App.tsx";
const app = new Hono();
app.get("/", async (c) => {
});
// Poll deployment data from Subhosting API
app.get("/deployments", async (c) => {
});
// Create deployment for the given project with the Subhosting API
app.post("/deployment", async (c) => {
});
// Create project for the given org with the Subhosting API
app.post("/project", async (c) => {
});
app.use("/*", serveStatic({ root: "./static" }));
Deno.serve(app.fetch);
接下来,让我们填写每个路由处理程序的逻辑。为了简化操作,我们现在将导入一个围绕 Subhosting API 的包装库,我们稍后将创建它
import Client from "./subhosting.ts";
const shc = new Client();
使用我们的 shc
包装库,我们可以为每个路由处理程序添加逻辑
app.get("/", async (c) => {
const projects = await (await shc.listProjects()).json();
return c.html(<App projects={projects} />);
});
// Poll deployment data from Subhosting API
app.get("/deployments", async (c) => {
const projectId = c.req.query("projectId") || "";
const dr = await shc.listDeployments(projectId, {
order: "desc",
});
const deployments = await dr.json();
return c.json(deployments);
});
// Create deployment for the given project with the Subhosting API
app.post("/deployment", async (c) => {
const body = await c.req.json();
const dr = await shc.createDeployment(body.projectId, {
entryPointUrl: "main.ts", // maps to `main.ts` under `assets`
assets: {
"main.ts": {
"kind": "file",
"content": body.code,
"encoding": "utf-8",
},
},
envVars: {}, // if you need the code to have access to credentials, etc.
});
const deploymentResponse = await dr.json();
return c.json(deploymentResponse);
});
// Create project for the given org with the Subhosting API
app.post("/project", async (c) => {
const body = await c.req.parseBody();
const pr = await shc.createProject(body.name as string);
const projectResponse = await pr.json();
console.log(projectResponse);
return c.redirect("/");
});
在我们继续之前,让我们深入了解一下我们发送以创建部署的 payload
entryPointUrl: "main.ts", // maps to `main.ts` under `assets`
assets: {
"main.ts": {
"kind": "file",
"content": body.code,
"encoding": "utf-8",
},
},
envVars: {}, // if you need the code to have access to credentials, etc.
- entryPointUrl: 此字符串是作为部署入口点的文件名。请注意,此值必须映射到
assets
下的键 - assets: 这是一个文件、脚本以及部署运行所需的任何内容的 JSON 对象。我们的示例非常简单,因此它是一个单独的文件 (
main.ts
),但对于更复杂的部署,可能会变得非常大,包含许多文件。 - envVars: 您可以在此处指定环境变量,这些变量将在代码执行时存在。如果您希望您的代码能够访问 API 凭据或其他配置级别的信息以正常工作,这将非常有用。
要了解有关使用 Subhosting API 创建部署的更多信息,请查看我们的文档。
接下来,让我们在 subhosting.ts
中创建我们的 Subhosting 客户端。
创建 Subhosting API 包装库
让我们在项目的根目录中创建一个新的 subhosting.ts
文件,它将作为 Subhosting API 的包装器。在此文件中,我们将为 ClientOptions
定义一个接口,以及一个 Client
类,该类将具有这些字段 accessToken
、orgId
和 clientOptions
,此外还有一个构造函数,用于初始化类的实例变量并进行简单的错误处理
export interface ClientOptions {
endpoint?: string;
}
export default class Client {
accessToken: string;
orgId: string;
clientOptions: ClientOptions;
constructor(accessToken?: string, orgId?: string, options?: ClientOptions) {
const at = accessToken ?? Deno.env.get("DEPLOY_ACCESS_TOKEN");
if (!at) {
throw new Error(
"A Deno Deploy access token is required (or set DEPLOY_ACCESS_TOKEN env variable).",
);
}
const org = orgId ?? Deno.env.get("DEPLOY_ORG_ID");
if (!org) {
throw new Error(
"Deno Subhosting org ID is required (or set DEPLOY_ORG_ID env variable).",
);
}
this.accessToken = at;
this.orgId = org;
this.clientOptions = Object.assign({
endpoint: "https://api.deno.com/v1",
}, options);
}
}
接下来,让我们创建我们在 main.tsx
中导入和使用的函数。在我们这样做之前,让我们在文件顶部导入以下辅助函数 urlJoin
和 normalize
import { normalize, urlJoin } from "https://deno.land/x/url_join@1.0.0/mod.ts";
请注意,在 我们的 GitHub 仓库中,我们内联了这两个函数,因为它们非常简单。
让我们定义一个方便的 getter,orgUrl
,它返回组织 URL 片段
export default class Client {
// ...
get orgUrl() {
return `/organizations/${this.orgId}`;
}
// ...
}
完成此操作后,我们可以定义我们在 main.tsx
中导入和使用的函数
fetch
listProjects
createProject
listDeployments
listAppLogs
createDeployment
使用附加函数,您的 Client
将如下所示
export default class Client {
// ...
/**
* A wrapper around "fetch", preconfigured with your subhosting API info.
*/
async fetch(url: string, options?: RequestInit): Promise<Response> {
const finalUrl = urlJoin(this.clientOptions.endpoint, url);
const finalHeaders = Object.assign({
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
}, options?.headers || {});
const finalOptions = Object.assign({}, options, { headers: finalHeaders });
return await fetch(finalUrl, finalOptions);
}
/**
* Get a list of projects for the configured org, with optional query params
*/
// deno-lint-ignore no-explicit-any
async listProjects(query?: any): Promise<Response> {
const qs = new URLSearchParams(query).toString();
return await this.fetch(`${this.orgUrl}/projects?${qs}`, { method: "GET" });
}
/**
* Create a project within the configured organization for the client.
*/
async createProject(name?: string): Promise<Response> {
return await this.fetch(`${this.orgUrl}/projects`, {
method: "POST",
body: JSON.stringify({ name }),
});
}
/**
* Get a list of deployments for the given project, with optional query params.
*/
// deno-lint-ignore no-explicit-any
async listDeployments(projectId: string, query?: any): Promise<Response> {
const qs = new URLSearchParams(query).toString();
return await this.fetch(`/projects/${projectId}/deployments?${qs}`, {
method: "GET",
});
}
/**
* Get a list of logs for the given deployment, with optional query params
*/
// deno-lint-ignore no-explicit-any
async listAppLogs(deploymentId: string, query?: any): Promise<Response> {
const qs = new URLSearchParams(query).toString();
return await this.fetch(`/deployments/${deploymentId}/app_logs?${qs}`, {
method: "GET",
});
}
/**
* Create a new deployment for the given project by ID.
*/
async createDeployment(
projectId: string,
// deno-lint-ignore no-explicit-any
deploymentOptions: any,
): Promise<Response> {
return await this.fetch(`/projects/${projectId}/deployments`, {
method: "POST",
body: JSON.stringify(deploymentOptions),
});
}
有关完整的 subhosting.ts
代码,其中包括 TSDoc 样式注释,请参阅 GitHub 仓库。如果您有兴趣深入研究 Subhosting API 端点,请查看我们的 API 参考。
我们服务器的路由处理程序的逻辑最终应该完成。下一步是定义我们的前端组件。
App.tsx
中构建前端
在 让我们创建 App
JSX 组件,我们在 main.tsx
中导入它。
这是一个简单的服务器端渲染的 JSX 组件。需要指出几件事
有两个
<script>
标签导入/ace/ace.js
,这是一个功能齐全的浏览器内 IDE 库,以及app.js
,一些用于简单客户端交互的原生 JavaScript,我们稍后将深入探讨
传递到此组件的唯一 props 是
projects
,它是一个表示您的 Subhosting 项目的对象数组。我们将使用map
返回<option>
元素列表,该列表将添加到<select>
元素中请注意,
<div id="deployments">
是部署列表的父元素。我们将在app.js
中使用原生 JavaScript 来持续设置其innerHTML
。
您的 App.tsx
应该看起来像这样
/** @jsx jsx */
import { jsx } from "$hono/jsx/index.ts";
// deno-lint-ignore no-explicit-any
export default function App({ projects }: { projects?: any }) {
// deno-lint-ignore no-explicit-any
const projList = projects?.map((p: any) => {
return <option value={p.id}>{p.name}</option>;
});
return (
<html>
<head>
<title>Basic Browser IDE (Deno Subhosting)</title>
<link rel="stylesheet" href="/styles.css" />
<script src="/ace/ace.js"></script>
<script src="/app.js"></script>
</head>
<body>
<nav>
<h1>
Basic Browser IDE
</h1>
<div id="project-selector">
<select id="project-list">
{projList}
</select>
<form action="/project" method="POST">
<button type="submit" id="new-project">
Generate New Project
</button>
</form>
</div>
</nav>
<main>
<div style="position:relative;height:100%;width:100%;">
<div id="editor-container">
<div id="editor"></div>
</div>
<div id="deployments-container">
<h3>Deployments</h3>
<div id="deployments"></div>
</div>
<button id="deploy-button">Save & Deploy</button>
</div>
</main>
</body>
</html>
);
}
接下来,让我们创建我们的客户端 JavaScript。
app.js
的客户端 JavaScript
使用 Ace 和 让我们创建一个新目录 static
,在其中添加
- ace 库
- app.js
- styles.css
让我们从 app.js
开始。当窗口加载时,我们需要初始化编辑器,将事件处理程序绑定到 #deploy-button
和 #project-list
,并每五秒调用一次 pollData()
(我们稍后将定义),以获取当前 projectId
的部署列表
let editor;
window.onload = function () {
// Initialize editor
editor = ace.edit("editor");
editor.session.setTabSize(2);
editor.setTheme("ace/theme/chrome");
editor.session.setMode("ace/mode/typescript");
editor.setValue(
`Deno.serve(() => {
console.log("Responding hello...");
return new Response("Hello, subhosting!");
});`,
-1,
);
// Attach event handler for deploy button
document.getElementById("deploy-button").addEventListener(
"click",
saveAndDeploy,
);
// Immediately refresh deployments when new project selected
document.getElementById("project-list").addEventListener("change", pollData);
// Poll for deployment and log data for the selected project
setInterval(pollData, 5000);
pollData();
};
接下来,让我们定义以下函数
pollData
: 从/deployments
端点获取给定当前projectId
的部署列表,并使用setDeployments
显示它们saveAndDeploy
: 获取projectId
和code
,然后通过向/deployment
端点发出POST
请求来创建部署getProjectId
: 从<select id="project-list">
获取项目 IDsetDeployments
: 给定一个部署数组,创建显示部署信息所需的 HTML,例如部署 URL 的链接、部署状态以及部署创建时间
async function pollData() {
const projectId = getProjectId();
try {
// Get list of all deployments
const dr = await fetch(`/deployments?projectId=${projectId}`);
const deployments = await dr.json();
setDeployments(deployments);
} catch (e) {
console.error(e);
}
}
async function saveAndDeploy(e) {
const $t = document.getElementById("deployments");
const currentHtml = $t.innerHTML;
$t.innerHTML = "<p>Creating deployment...</p>" + currentHtml;
const projectId = getProjectId();
const dr = await fetch(`/deployment`, {
method: "POST",
body: JSON.stringify({
projectId,
code: editor.getValue(),
}),
});
const deployResult = await dr.json();
}
function getProjectId() {
const $project = document.getElementById("project-list");
return $project.value;
}
function setDeployments(deployments) {
const $t = document.getElementById("deployments");
if (!deployments || deployments.length < 1) {
$t.innerHTML = "<p>No deployments for this project.</p>";
} else {
let html = "";
deployments.forEach((deployment) => {
html += `<div class="deployment-line">
<a href="https://${deployment.domains[0]}" target="_blank">
${deployment.domains[0] || "URL pending..."}
</a>
<span class="timestamp">
<span class="status ${deployment.status}">${deployment.status}</span>
${deployment.updatedAt}
</span>
</div>`;
});
$t.innerHTML = html;
}
}
完成所有这些操作后,您的应用程序应该就完成了。要启动服务器,请运行命令 deno task dev
。
关于部署限制的注意事项
截至 2024 年 1 月,活动部署在免费 Subhosting 计划中每天上限为 50 个。这可能会在您的测试期间引起问题,因为每次您在浏览器中保存代码时,都会创建一个新的部署。我们正在努力修改我们的计费结构以避免此问题,但如果您遇到此限制的任何问题,请联系 support@deno.com。
下一步是什么?
云 IDE 正变得越来越普遍,作为编辑、编写和部署代码的无摩擦方式。在您的开发人员需要在您的产品之外的自己的工作流程中构建和设置服务器的情况下,它们可以改善开发人员的体验。虽然您可以构建自己的基础设施来部署和运行第三方代码,但您也必须维护和扩展它,以及考虑运行不受信任代码的安全影响。
使用 Deno Subhosting 可以轻松构建云 IDE 以部署和运行第三方代码,Deno Subhosting 专为最大安全性而设计,可以通过 REST API 以编程方式启动部署。我们希望本教程和启动器模板能够为构建您的高级云 IDE 或将云 IDE 集成到您的产品中奠定良好的基础。