跳到主要内容
Deno 2.4 已发布,带来 deno bundle、字节/文本导入、OTel 稳定版等新功能
了解更多
how to build your own cloud ide with the Subhosting API

使用 Subhosting API 构建您自己的云 IDE

越来越多的 SaaS 平台允许其用户通过代码自定义产品,例如创建定制工作流或通过应用/集成市场。在产品中直接通过云 IDE 实现代码级定制,是一种流行的、阻力最小的方法。

入门 IDE 模板的演示。

在这篇文章中,我们将向您展示如何使用 Deno Subhosting API 构建您自己的云 IDE,该 API 允许您在 Deno Deploy 的全球 v8 隔离云上在几秒钟内以编程方式部署和运行代码。我们将详细介绍我们的 Subhosting IDE 入门模板,它基于 HonoAce Editor 和 Deno 构建。

或者在 YouTube 上观看配套的视频教程。

设置您的项目

在开始之前,我们需要准备以下内容:

在一个新文件夹中创建以下项目结构:

subhosting_starter_ide/
├── .env
├── App.tsx
├── deno.json
└── main.tsx

接下来,您需要创建以下环境变量:

  • 一个 DEPLOY_ACCESS_TOKEN,您可以在您的 Deno Deploy 账户中生成
  • 一个 DEPLOY_ORG_ID,您可以从您的组织页面获取

Where to find your organization 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 类,该类将包含 accessTokenorgIdclientOptions 这些字段,此外还有一个用简单错误处理初始化类实例变量的构造函数。

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 中导入和使用的函数。在此之前,让我们在文件顶部导入以下辅助函数:urlJoinnormalize

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 组件。有几点需要指出:

  1. 有两个 <script> 标签导入了:

    • /ace/ace.js,这是一个功能齐全的浏览器内 IDE 库,以及
    • app.js,一些用于简单客户端交互的纯 JavaScript,我们稍后会深入探讨
  2. 传递给此组件的唯一 props 是 projects,它是一个表示您的 Subhosting 项目的对象数组。我们将使用 map 返回 <option> 元素的列表,该列表会添加到 <select> 元素中。

  3. 请注意,<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。

使用 Ace 和 app.js 的客户端 JavaScript

让我们创建一个新目录 static,我们将在其中添加:

让我们从 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:获取 projectIdcode,然后向 /deployment 端点发送 POST 请求创建部署
  • getProjectId:从 <select id="project-list"> 中获取项目 ID
  • setDeployments:给定一个部署数组,创建显示部署信息所需的 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 集成到您的产品中奠定良好基础。