跳至主要内容
Deno 2 终于来了 🎉️
了解更多
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 编辑器 和 Deno。

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

设置项目

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

在新的文件夹中创建以下项目结构

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

接下来,您必须创建以下环境变量

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/[email protected]/"
  }
}

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("/");
});

在继续之前,让我们深入研究我们用来创建部署的有效负载

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/[email protected]/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 组件。需要指出几点

  1. 有两个 <script> 标记导入

    • /ace/ace.js,这是一个功能齐全的浏览器内 IDE 库,以及
    • app.js,一些用于朴素客户端交互的普通 JavaScript,我们将在后面详细介绍
  2. 传递到此组件中的唯一道具是 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 次。这可能会在您的测试过程中造成问题,因为每次您在浏览器中保存代码时,都会创建一个新的部署。我们正在努力改变我们的计费结构以避免此问题,但如果您遇到此限制导致的任何问题,请联系 [email protected]

下一步是什么?

云 IDE 正在变得越来越普遍,它们提供了一种无缝的方式来编辑、编写和部署代码。当您的开发者需要在您产品之外的自己的工作流程中构建和设置服务器时,它们可以改善开发者体验。虽然您可以构建自己的基础设施来部署和运行第三方代码,但您还需要维护和扩展它,以及 考虑运行不受信任的代码的安全隐患

使用 Deno Subhosting 可以轻松地构建一个云 IDE 来部署和运行第三方代码,Deno Subhosting 旨在提供最大程度的安全,并且可以通过 REST API 以编程方式启动部署。我们希望本教程和入门模板能为您构建高级云 IDE 或将云 IDE 集成到您的产品中提供良好的基础。