☰
在 Discord 中运行 jspaint:Discord Embedded App Starter 的架构解析与本地开发实战
2026/9/27 23:42:32 网站建设 项目流程
  • 前端
  • 桌面应用
  • 图像处理

【免费下载链接】jspaint

🎨 Classic MS Paint, REVIVED + ✨Extras

项目地址:https://gitcode.com/gh_mirrors/js/jspaint
点击查看免费下载

导读

本指南以 jspaint 仓库中 discord-activity 子目录自带的 Discord Embedded App Starter 为核心,完整讲解如何把 jspaint 以 Discord Activity(嵌入式应用)的形式跑起来:包括 vanilla JS 客户端与 Express + TypeScript 服务端的架构分工、OAuth2 环境变量的配置与类型安全、本地联调的三步命令,以及服务端/api/token、多用户房间等源码级实现细节。读完你将掌握从创建 Discord 应用到本地通过隧道预览完整 Activity 的全流程,并能基于现有骨架扩展自己的嵌入式应用。

说明:discord-activity/README.md是上游 Discord 官方 starter 示例文档,本仓库在引入时对其进行了深度改造(无 Vite 构建、无独立 client 包、直接静态托管 jspaint 本体)。本文以该文档为骨架,结合仓库内实际源码补充实现细节。

一、Starter 定位与整体架构

该 starter 的目标是:以最小成本让嵌入式应用在 Discord 中跑起来,同时把客户端与服务端拆成易于替换的模块。文档明确了两端的技术选型:

  • 客户端(front-end):vanilla JS(原生 JavaScript),不依赖框架;
  • 服务端(back-end):Express + TypeScript。

在本仓库中,这一结构被进一步简化。查看 discord-activity 目录可以看到 pnpm workspace 布局:

discord-activity/ ├── package.json # workspace 根,定义 dev / tunnel 脚本 ├── package-lock.json └── packages/ └── server/ ├── environment.d.ts # 服务端环境变量类型声明 ├── package.json # Express 服务端及 dev/build 脚本 ├── tsconfig.json └── src/ ├── app.ts # Express 应用入口 ├── utils.ts # fetchAndRetry(429 限流重试) └── shared/ └── hello.ts # 共享目录示例

值得注意的差异点:原版 starter 拥有packages/client与packages/server两个包,而本仓库服务端源码中的注释明确写道:

"I'm hacking this to work without Vite, without a build step, and without a client folder / monorepo structure."(见 app.ts)

也就是说,jspaint 的改编版去掉了独立 client 包和 Vite 构建步骤,改用 Express 直接以静态文件方式托管 jspaint 的源码目录(clientSourcePath向上回溯指向仓库根目录),详见下文“服务端源码解析”。

二、前置准备:创建 Discord 应用并获取 OAuth2 凭据

按照 Discord 官方文档Building an Activity的 Step 1 指引,先在 Discord Developer Portal 创建应用。核心要点是:

  1. 在 Developer Portal 新建 Application;
  2. 将应用配置为支持 Activity / Embedded App SDK 所需的能力;
  3. 找到应用的OAuth2 Client ID与Client Secret,这是后文.env中两个必需变量的来源。

完成上述配置后,才进入本仓库的代码配置环节。

三、环境变量配置与类型安全

原文档要求,在discord-activity目录下创建.env文件,并填入两个 OAuth2 变量:

VITE_CLIENT_ID=123456789012345678 CLIENT_SECRET=abcdefghijklmnopqrstuvwxyzabcdef
  • VITE_CLIENT_ID:Discord 应用的 Client ID(数字串);
  • CLIENT_SECRET:Discord 应用的 Client Secret(密钥串),仅服务端使用,切勿暴露到客户端。

服务端加载.env的位置见 app.ts:

dotenv.config({ path: "../../.env" });

该路径相对于packages/server目录向上两级,恰好指向discord-activity/.env,与文档要求的位置一致。

添加新环境变量的标准三步流程

原文档规定,新增环境变量必须同步完成三步,以保证消费侧的类型安全:

  1. 在.env中添加键值对;
  2. (原版)在packages/client/src/vite-env.d.ts中添加键名;
  3. 在 discord-activity/packages/server/environment.d.ts 中添加键名。

由于本仓库改编版没有 client 包,客户端侧的类型声明文件在本仓库中并不存在;服务端侧的类型声明位于packages/server/environment.d.ts。从源码看,该文件为process.env声明了如下类型契约:

declare global { namespace NodeJS { interface ProcessEnv { VITE_CLIENT_ID: string; CLIENT_SECRET: string; NODE_ENV: "development" | "production"; PORT?: string; PWD: string; } } }

其中PORT为可选变量,对应服务端监听端口(默认 1999,见下文)。服务端 tsconfig 通过include显式纳入了该类型声明文件(见 tsconfig.json),确保process.env.VITE_CLIENT_ID等访问在编译期即被类型检查覆盖。

四、服务端源码解析:Express + TypeScript

4.1 端口与启动

app.ts 定义端口:

const port: number = Number(process.env.PORT) || 1999;

即默认监听1999端口,可通过.env中的PORT覆盖。启动时打印App is listening on port 1999 !。

4.2 客户端代码的 CLIENT_ID 注入(monkey-patching)

服务端并不直接修改 jspaint 源码文件,而是运行时替换占位符。源码关键逻辑:

const clientId = process.env.VITE_CLIENT_ID; const clientIdNeedle = "$$$$$CLIENT_ID$$$$$"; // same length as the client ID, just in case const urlPathForPatching = "/src/discord-activity-client.js"; const fsPathForPatching = path.join(clientSourcePath, urlPathForPatching);

启动时校验 src/discord-activity-client.js 存在,然后通过路由动态响应:

app.get(urlPathForPatching, (req, res) => { fs.readFile(fsPathForPatching, "utf8", (err, data) => { ... res.send(data.replace(clientIdNeedle, clientId)); }); });

即访问/src/discord-activity-client.js时,将文件内的$$$$$CLIENT_ID$$$$$占位符实时替换为.env中的真实 Client ID(不写缓存,便于开发期热更新)。对应地,客户端文件 src/discord-activity-client.js 中声明:

const CLIENT_ID = "$$$$$CLIENT_ID$$$$$"; // monkey-patched in by the server const APPLICATION_ID = CLIENT_ID; // seems to be the same const { DiscordSDK } = Discord; const discordSdk = new DiscordSDK(CLIENT_ID); await discordSdk.ready();

SDK 本体来自仓库内置的 lib/discord-embedded-app-sdk-v1.2.0-bundled-with-skypack.js。

4.3 静态托管与安全过滤

服务端通过express.static(clientSourcePath)直接托管整个 jspaint 仓库根目录(app.ts),从而免去构建步骤。由于会把.git、.env等敏感文件也暴露出来,服务端在静态中间件之前加了拦截路由:

app.use((req, res, next) => { // Must be case-insensitive for Windows FS! if (req.path.match(/\.(git|history|env)/i)) { res.status(403).send("Forbidden"); return; } next(); });

任何路径中包含.git、.history、.env(大小写不敏感,兼容 Windows 文件系统)的请求都会返回 403。源码注释也坦诚地标记了 TODO:更稳妥的方案是维护一份明确的“允许访问文件白名单”。

4.4 OAuth2 Token 端点:/api/token

客户端拿到 Discord 返回的code后,会向服务端/api/token发起 POST,由服务端代为换取access_token(app.ts):

app.post("/api/token", async (req: Request, res: Response) => { const response = await fetchAndRetry(`https://discord.com/api/oauth2/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: process.env.VITE_CLIENT_ID, client_secret: process.env.CLIENT_SECRET, grant_type: "authorization_code", code: req.body.code, }), }); const { access_token } = await response.json(); res.send({ access_token }); });

将client_secret放在服务端、由服务端统一请求 Discord API,是避免密钥泄漏到客户端的标准做法。这里还调用了自定义的fetchAndRetry工具。

4.5 fetchAndRetry:限流与失败重试

utils.ts 实现了一个带重试语义的 fetch 封装,核心逻辑为:

  • 请求返回429(Discord 限流)时,读取响应头retry_after(秒),sleep相应时长后重试,最多重试 3 次;
  • 其他请求异常(网络失败等)时,等待 1 秒后重试,同样最多 3 次;
  • 重试次数耗尽后抛出异常。
if (response.status === 429 && nRetries > 0) { const retryAfter = Number(response.headers.get("retry_after")); ... await sleep(retryAfter * 1000); return await fetchAndRetry(input, init, nRetries - 1); }

这套逻辑直接对应 Discord 官方 Rate Limits 文档描述的retry_after语义,源码注释也给出了对应出处说明。

4.6 简单多用户房间(Rooms)

app.ts中还有一组用于“Simple multiplayer image editing”的实验性端点(app.ts):

const rooms: { [key: string]: string } = {}; app.get("/api/rooms/:roomId/data", (req, res) => { const { roomId } = req.params; res.send(rooms[roomId]); }); app.put("/api/rooms/:roomId/data", bodyParser.text({ type: "*/*" }), (req, res) => { const { roomId } = req.params; const image = req.body; rooms[roomId] = image; res.send({ success: true }); });

它用内存对象保存每个 roomId 对应的图片数据(以 data URI 字符串存储),提供 GET/PUT 两个接口,客户端侧由 sessions.js 中的RESTSession配合使用:监听画布变更后 debounce 100ms 将main_canvas.toDataURL()PUT 到服务端,同时每 1000ms 轮询 GET 拉取远端数据,实现轻量级的多人协作(注释注明这是单向同步、服务端内存态、无鉴权,属实验性质)。

五、jspaint 客户端侧的 Discord 集成

服务端之外,jspaint 本体的集成逻辑也值得关注,它验证了上述 starter 的端到端链路。

5.1 环境识别

helpers.js 中通过 URL 参数识别是否处于 Discord 嵌入式环境:

export const is_discord_embed = query_params.get("frame_id") != null;

Discord 嵌入式应用 SDK 要求 URL 携带frame_id参数(SDK 内置逻辑亦会校验该参数)。同时 functions.js 在更新 URL 时特意保留 query string,注释写明“The Discord Activity needs to preserve the query string, so it's exempt from this”。

5.2 会话引导与 OAuth 全流程

在 sessions.js 中,当is_discord_embed为真时,动态导入客户端模块并执行:

const { discordSdk, newAuth, guildMember, handleExternalLinks, discordActivitySystemHooks } = await import("./discord-activity-client.js"); handleExternalLinks(); change_url_param("session", `discord-activity-${discordSdk.instanceId}`); Object.assign(window.systemHooks, discordActivitySystemHooks);

即以 Discord Activity 实例 ID 作为 jspaint 的 session ID 启动会话,并把 Discord 特定的 systemHooks 注入全局。客户端模块 discord-activity-client.js 内部完成标准 OAuth 授权链路:

  1. discordSdk.commands.authorize(...)获取授权code(scope 至少需要一个,仓库默认使用identify,并注释了guilds.members.read、messages.read等可选 scope 的用途);
  2. 将codePOST 到服务端/api/token换取access_token;
  3. 用access_token调用discordSdk.commands.authenticate(...)完成身份认证;
  4. 可选拉取/discord/api/users/@me/guilds/${discordSdk.guildId}/member获取服务器内昵称与头像。

5.3 systemHooks:把保存转化为 Discord 分享

discordActivitySystemHooks重写了 jspaint 的保存行为(discord-activity-client.js)。由于嵌入式环境无法使用showSaveFilePicker(会抛SecurityError: Cross origin sub frames aren't allowed to show a file picker)、blob:URL 和数据 URI 也均无法触发下载,实现改为:弹出 jspaint 自带的“保存为”对话框选择格式 →getBlob()生成图片 → 调用shareImage():

const body = new FormData(); body.append("file", imageFile); const attachmentResponse = await fetch(`${DISCORD_API_BASE}/applications/${APPLICATION_ID}/attachment`, { method: "POST", headers: { Authorization: `Bearer ${access_token}` }, body, }); const mediaUrl = attachmentJson.attachment.url; await discordSdk.commands.openShareMomentDialog({ mediaUrl });

即通过 Discord 的 attachments API 上传图片获得临时 CDN URL,再调用openShareMomentDialog弹出 Discord 内置分享对话框。源码注释记录了这段从blob:URL、data URI 一路试到 attachment API 的探索过程,对理解嵌入式环境的限制很有价值。

六、本地运行:三步启动

原文档要求在discord-activity目录下依次执行三条命令:

pnpm install # only need to run this the first time pnpm dev pnpm tunnel # from another terminal

6.1 命令背后的脚本

从根 package.json 可以看到:

"dev": "pnpm run --filter \"./packages/**\" --parallel dev", "tunnel": "cloudflared tunnel --url http://localhost:3000"
  • pnpm dev通过 pnpm--filter匹配packages/**下所有包并--parallel并行执行各包的dev脚本;
  • 服务端包 packages/server/package.json 的dev为nodemon --watch src -e ts,ejs --exec $npm_execpath start,即监听src目录的.ts/.ejs变更自动重启,start会先执行build(rimraf dist+tsc编译)再运行node ./dist/app.js。

6.2 隧道:把本地服务暴露给 Discord

Discord 的嵌入式应用要求 Discord 客户端能够访问你的应用 URL,因此官方推荐配合隧道工具(如 cloudflared)做本地联调。pnpm tunnel即启动 cloudflared 将本地端口转发为公网 HTTPS 临时地址,随后在 Discord 开发者后台的 Activity URL 配置中填入该地址。

实战注意(源码层面的坑):tunnel脚本写死转发到http://localhost:3000,而服务端默认监听的是1999端口(Number(process.env.PORT) || 1999)。二者不一致时隧道将无法连通,建议二选一:在.env中设置PORT=3000,或把 tunnel 命令的端口改为 1999。

6.3 首次启动顺序建议

  1. 创建并配置好 Discord 应用(获取 Client ID / Secret);
  2. 在discord-activity/.env写入VITE_CLIENT_ID与CLIENT_SECRET;
  3. 首次运行pnpm install安装依赖;
  4. 终端 A 执行pnpm dev启动服务端;
  5. 终端 B 执行pnpm tunnel建立公网隧道,将输出的 HTTPS 地址填入 Discord Activity URL;
  6. 在 Discord 客户端中打开该 Activity 验证联调。

七、小结

原文档的核心要点——vanilla JS 客户端 + Express/TypeScript 服务端、OAuth2 环境变量、三步本地启动——在本仓库中均有对应实现,且被 jspaint 的实际集成进一步验证:

  • 环境变量经 environment.d.ts 获得类型安全;
  • 服务端 app.ts 承担静态托管、CLIENT_ID 注入、OAuth2 Token 交换与多用户房间;
  • utils.ts 提供符合 Discord 限流语义的重试封装;
  • 客户端 discord-activity-client.js 与 sessions.js 完成环境识别、授权认证与 systemHooks 注入。

如果你要基于这个骨架开发自己的 Discord Activity,建议按原文档的初衷“swap in pieces”:保留.env三步配置与 tunnel 工作流,替换服务端路由与客户端逻辑即可。若要落地到生产,还需关注源码注释中坦承的已知边界——房间数据为内存态且无鉴权、静态托管采用黑名单式过滤而非白名单、fetchAndRetry仅实现简单退避。

  • 前端
  • 桌面应用
  • 图像处理

【免费下载链接】jspaint

🎨 Classic MS Paint, REVIVED + ✨Extras

项目地址:https://gitcode.com/gh_mirrors/js/jspaint
点击查看免费下载

相关推荐

上一篇:FSPagerView自定义布局教程:轻松实现瀑布流与网格滑动视图
下一篇:LLM-Graph-Builder终极指南:5分钟从零构建AI知识图谱

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询