使用 Subhosting API 构建您自己的云 IDE
越来越多的 SaaS 平台允许其用户通过代码自定义产品,例如创建定制工作流或通过应用/集成市场。在产品中直接通过云 IDE 实现代码级定制,是一种流行的、阻力最小的方法。
在这篇文章中,我们将向您展示如何使用 Deno Subhosting API 构建您自己的云 IDE,该 API 允许您在 Deno Deploy 的全球 v8 隔离云上在几秒钟内以编程方式部署和运行代码。我们将详细介绍我们的 Subhosting IDE 入门模板,它基于 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),
});
}
有关包含 TSDoc 风格注释的完整 subhosting.ts
代码,请参阅 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
:根据当前projectId
从/deployments
端点获取部署列表,并使用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,它旨在提供最高安全性,并可以通过 REST API 以编程方式启动部署。我们希望本教程和入门模板能为您构建高级云 IDE 或将云 IDE 集成到您的产品中奠定良好基础。