MoreRSS

site iconrxliuli | 琉璃修改

Web 前端开发,在日本,喜欢自称「吾辈」让我很难受。。博客主要记录一些技术相关的东西
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

rxliuli | 琉璃的 RSS 预览

在 Chrome 插件中将 ArrayBuffer 从网页传递到 Devtools Panel

2024-11-13 02:52:02

背景

最近使用了 ZenFS 在浏览器中模拟文件系统,以在浏览器中像使用 node fs api 一样存储一些文件。但想要可视化的检查当前存储的文件时,却没有一个可以直观的工具来完成。所以就创建了一个 Chrome Devtools Extension ZenFS Viewer,以实现这个目标。在此过程中就遇到了如何传递 ArrayBuffer 从网页到 devtools panel 线程的问题,一些尝试如下。

browser.devtools.inspectedWindow.eval

首先尝试了最简单的方法,browser.devtools.inspectedWindow.eval 可以在网页执行任意代码并得到结果,例如

1
browser.devtools.inspectedWindow.eval(`__zenfs__.readdir('/')`)

然而 inspectedWindow.eval 并不支持 Promise 返回值,例如下面的表达式无法得到 Promise 结果

1
browser.devtools.inspectedWindow.eval(`await __zenfs__.promises.readdir('/')`)

同样的,也无法支持 ArrayBuffer。所以这个显而易见的 API 被放弃了。

1
2
3
browser.devtools.inspectedWindow.eval(
`await __zenfs__.promises.readFile('/test.png')`,
)

chrome.runtime.sendMessage

接下来就是思想体操的时候了,一开始考虑的方案就是通过 devtools panel => background script => content-script(isolation) => content-script(main) 进行中转通信,以此在 devtools panel 中调用网页的全局变量并传递和获取 ArrayBuffer 的响应。大概示意图如下

before.excalidraw.svg

然而在使用 chrome.runtime.sendMessage 时也遇到了和 inspectedWindow.eval 类似的问题,无法传递 ArrayBuffer,仅支持 JSON Value。当然可以序列化 ArrayBuffer 为 JSON,但在传输视频之类的大型数据时并不现实。

解决

之后经过一些搜索和思考,找到了一种方法可以绕道 chrome.runtime.message,因为注入的 iframe 和 devtools panel 同源,所以可以使用 BroadcastChannel 通信,而 iframe 和注入的 content-script(main world) 之间尽管不同源,但仍然可以通过 postMessage/onMessage 来通信,并且两者都支持传递 ArrayBuffer,这样便可绕道成功。

after.excalidraw.svg

Content-Script <=> Iframe

网页与注入的 iframe 之间,通信可以使用基本的 postMessage/onMessage 实现,为了减少冗余代码,这里使用 comlink 来实现。

先看看注入的 content-script,它主要是负责对 iframe 暴露一些 API 的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// entrypoints/main-content.ts
import { expose, windowEndpoint } from 'comlink'

export default defineUnlistedScript(() => {
const map = new Map<string, ArrayBuffer>()

interface IFS {
readFile: (path: string) => Promise<ArrayBuffer>
writeFile: (path: string, data: ArrayBuffer) => Promise<void>
readdir: (path: string) => Promise<string[]>
}

expose(
{
readFile: async (path: string) => {
return map.get(path) || new Uint8Array([1, 2, 3]).buffer
},
writeFile: async (path: string, data: ArrayBuffer) => {
map.set(path, data)
},
readdir: async (path: string) => {
return Array.from(map.keys()).filter((p) => p.startsWith(path))
},
} as IFS,
windowEndpoint(
(document.querySelector('#inject-iframe')! as HTMLIFrameElement)
.contentWindow!,
),
)
console.log('main-content')
})

而在 iframe 中,则需要转发所有来自 BroadcastChannel 的请求通过 postMessage 传递到上层注入的 content-script 中,其中在每次传递 ArrayBuffer 时都需要使用 transfer 来转移对象到不同线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// entrypoints/iframe/main.ts
import { expose, transfer, windowEndpoint, wrap } from 'comlink'

interface IFS {
readFile: (path: string) => Promise<ArrayBuffer>
writeFile: (path: string, data: ArrayBuffer) => Promise<void>
readdir: (path: string) => Promise<string[]>
}

async function main() {
const tabId = (await browser.tabs.getCurrent())!.id
if (!tabId) {
return
}
const ipc = wrap<IFS>(windowEndpoint(globalThis.parent))
const bc = new BroadcastChannel(
`${browser.runtime.getManifest().name}-iframe-${tabId}`,
)
expose(
{
readFile: async (path: string) => {
const r = await ipc.readFile(path)
// 将 ArrayBuffer 通过 transfer 传递回 devtools-panel 中
return transfer(r, [r])
},
writeFile: async (path: string, data: ArrayBuffer) => {
// 将 ArrayBuffer 通过 transfer 传递到 content-script 中
await ipc.writeFile(path, transfer(data, [data]))
},
readdir: async (path: string) => {
console.log('readdir', path)
return await ipc.readdir(path)
},
} as IFS,
bc,
)

console.log('iframe main')
}

main()

Iframe <=> Devtools

而在 Devtools 中,要做的事情有一点点多 🤏。首先需要注入两个 content-script,而其中 isolation-content.js 是用来创建 iframe 的 content-script。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// entrypoints/devtools-panel/main.ts
import { PublicPath } from 'wxt/browser'

async function injectScript() {
const includeIframe = await new Promise((resolve) => {
browser.devtools.inspectedWindow.eval(
`!!document.querySelector('#inject-iframe')`,
(result) => {
resolve(result)
},
)
})
if (includeIframe) {
return
}
const tabId = browser.devtools.inspectedWindow.tabId
if (!tabId) {
return
}
await browser.scripting.executeScript({
target: { tabId },
files: ['/isolation-content.js' as PublicPath],
world: 'ISOLATED',
})
await browser.scripting.executeScript({
target: { tabId },
files: ['/main-content.js' as PublicPath],
world: 'MAIN',
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// entrypoints/isolation-content.ts
function createIframeUi() {
const wrapper = document.createElement('div')
wrapper.style.height = '0'
wrapper.style.width = '0'
const ifr = document.createElement('iframe')
wrapper.appendChild(ifr)
ifr.src = browser.runtime.getURL('/iframe.html')
ifr.style.width = '0'
ifr.style.height = '0'
ifr.style.zIndex = '-9999'
ifr.style.border = 'none'
ifr.id = 'inject-iframe'
document.body.appendChild(wrapper)
return ifr
}

export default defineUnlistedScript(() => {
console.log('isolation-content', createIframeUi())
})

接下来就可以在 devtools-panel 中获取数据了,由于 iframe 的注入完成的时机并不能确定,所以需要加个简单的通知机制。

1
2
3
4
5
6
7
8
9
10
11
12
// entrypoints/iframe/main.ts
import { wrap } from 'comlink'

async function main() {
// Other code...
console.log('iframe main')
await wrap<{
onReady: () => void
}>(bc).onReady()
}

main()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// entrypoints/devtools-panel/main.ts
async function main() {
await injectScript()
interface IFS {
readFile: (path: string) => Promise<ArrayBuffer>
writeFile: (path: string, data: ArrayBuffer) => Promise<void>
readdir: (path: string) => Promise<string[]>
}
const tabId = browser.devtools.inspectedWindow.tabId
if (!tabId) {
return
}
const bc = new BroadcastChannel(
`${browser.runtime.getManifest().name}-iframe-${tabId}`,
)
await new Promise<void>((resolve) => expose({ onReady: resolve }, bc))
console.log('onReady')
// Test code...
const ipc = wrap<IFS>(bc)
const r = await ipc.readdir('/')
console.log(r)
const data = new Uint8Array([1, 2, 3]).buffer
await ipc.writeFile('/test.txt', transfer(data, [data]))
const r2 = await ipc.readFile('/test.txt')
console.log(r2)
}

main()

完整代码参考: https://github.com/rxliuli/devtools-webpage-message-demo

总结

至今为止,仍然没有简单的方法来支持 Devtools Extension 与 Webpage 之间的通信来替代 inspectedWindow.eval,确实是有点神奇。

Mac Tips 分享:创建一个 Shortcut 快速调整窗口尺寸

2024-10-16 00:26:52

场景

之前发布 Chrome 扩展到 Chrome WebStore 时,WebStore 要求提供几张截图,而且必须是 1280x800 或者 640x400,而如果想要手动调整窗口大小为特定尺寸的话,会非常痛苦。所以一直想找到一种方法可以快速调整窗口尺寸到指定的大小。之前尝试过 AppleScript,甚至想过开发一个 Mac 原生应用来解决,但都遇到了一些问题(主要是权限问题),直到昨天看到一篇文章启发了吾辈。之前从未使用过 Shortcuts,没想到 Mac 自带的自动化工具还不错,完全解决了吾辈的问题。

尝试

AppleScript

在早前,吾辈曾经就该问题询问过 AI,得到的答案是创建一个 AppleScript 来自动化这个操作,看起来脚本很简单。

1
2
3
4
5
6
7
tell application "System Events"
set frontApp to name of first application process whose frontmost is true
tell process frontApp
set position of window 1 to {0, 0}
set size of window 1 to {1280, 800}
end tell
end tell

事实上,如果在 Automactor 中直接执行,也确实符合预期,可以修改窗口大小。但在吾辈将之保存为 App 后,再次运行却出现了权限错误。

1
2
Can’t get window 1 of «class prcs» "Resize1280x800" of application "System Events". Invalid index.
System Events got an error: Can’t get window 1 of process "Resize1280x800". Invalid index. (-1719)

而 System Event 也确实是给了的,不知道发生了什么。🤷

1729049171168.jpg

Mac App 开发

在使用简单的脚本实现受挫之后,吾辈考虑快速开发一个 Mac App 来解决这个问题,但实际上仍然遇到了一些问题。主要是对 Swift 没有任何先验知识,XCode 相比之前使用 IDE(Jetbrains/VSCode)非常难用,再加上 AI 对 Swift 代码生成和修改支持并不好,所以开发起来很痛苦,而且最终仍然遇到了与上面 AppleScript 类似的权限问题。吾辈猜测这是个愚蠢的问题,熟悉 Mac App 开发的人或许十分钟就能解决,但确实卡住了吾辈。

Shortcuts

终于,Shortcuts 可以以低代码的方式创建一个应用。基本思路是,获取所有窗口 => 找到置顶的窗口 => 修改窗口尺寸。

  1. 拖拽一个 Find Windows Action
    1729049857159.jpg
  2. 修改 Find Windows 配置
    1729049910814.jpg
  3. 再拖拽一个 Resize Window Action
    1729049972605.jpg
  4. 修改 Resize Window 的配置
    1729050031565.jpg
  5. 尝试运行一下,确保没有问题
    1729050099222.jpg
  6. 现在,可以使用 Spotlight Search 输入 Resize Window 来快速运行这个 Shortcut 啦
    1729050190520.jpg

另外吾辈已经把这个 Shortcut 导出放到 GitHub 上了,可以自行下载使用:https://github.com/rxliuli/mac-resize-window

参考

The Easiest Way to Resize All Windows on Your Mac Simultaneously to the Same Dimensions

旅行 2024-09

2024-09-27 16:21:46

自从九月初从广州出发继续北上旅行,刚好躲过了一次台风摩羯。虽然旅行仍然能带来一些新鲜感,但或多或少已经有些不足了,所以在江浙连续碰上两次台风之后,九月下旬便匆匆赶回来了。

cover

衡山

第一站去往了衡山,之前前往山东时没能爬上泰山,这次顺路去爬下衡山。刚到衡山就被出租车上了一课,滴滴叫了车但司机实收很低线下想让吾辈额外付费,之前在广西阳朔、后面在江苏镇江都遇到过类似的糟心事,也许越是小地方越是不讲规则?

刚到就去了附近的万寿广场,35 度的天气下对吾辈和相机都是考验。

衡山 1
衡山 2

之后前往旁边的南岳大庙,本身吾辈并不信佛道,但拍拍风景也是好的。

衡山 3
衡山 4

晚上出来拍点夜景,偶然还看到了五岳特种兵的宣传牌,之前完全不知道这个活动。

衡山 5
衡山 6

次日一早开始爬衡山,说是爬山,也是要坐很久的区间车,在最后的山顶附近才开始爬。由于雾气很大,没能拍出来什么照片。相比于爬山爱好者,来此求神拜佛者更是络绎不绝。

衡山 7
衡山 8
衡山 9

影集

长沙

第二站选在了长沙,之前去了湖南的张家界,却没去省会城市,这次补上。不过前往之前刷到了一些关于臭豆腐的坏消息,所以臭豆腐一点也没尝。虽然耳边还记得那句“来长沙不吃长沙臭豆腐等于白来”的广告语。

先去城市里随便逛逛扫个街,有一说一,第一次看到把辣条作为宣传的。

长沙 1

大楼的阴凉处,好多正在直播的人,也许附近有“直播小区”?

长沙 2

途经坡子街,到处都是小吃和茶饮,尤其是茶颜悦色,非常显眼。

长沙 3

之后,来到湘江之畔,走在翠幕之下的河岸边,可以眺望对面的橘子洲。树荫之下,人们三三两两的坐着,或是聊天,或是休息。

长沙 4

途经太平老街,中午简单吃了个饭,随即前往河对岸,只是天色渐晚,遂未登岳麓山。

长沙 5

走了一天也有些累了,之后便回到青旅暂作休息。

长沙 6

次日早晨,前往湖南省博物馆,其中付费的两个场地人数不多,值得一看,尤其是数字科技展览部分。

长沙 7
长沙 8

不幸的是,下午刚出博物馆就遇上了暴雨,无奈之下只能打车回去,暂时避雨。直到傍晚,才出来去杜甫江阁附近观赏晚霞与夜景。

长沙 9
长沙 10
长沙 11

可能唯一留下不好印象的就是小巷之中贴的到处都是的标语了。众所周知,通常缺什么才写什么。。。

1727445989235.jpg

影集

南京

第三站是南京,可以说是整个行程中最满意的一个城市,是这次旅行中照片最多的城市。

当晚就去了知名的秦淮河畔,表面看上去光鲜亮丽无比。

南京 1
南京 2

实则水质较差,隐有臭味,把相机吓得都变形了。

南京 3

第二天前往中山陵和明孝陵,后者超级超级超级大,逛了好几个小时还没走完,直到下午 4 点钟才离开。

南京 4
南京 5

在离开中山陵,进入明孝陵之后,首先碰到了钟山文学馆,江南园林看起来永远都很舒服。

南京 6

之后是颜真卿碑林,第一看到时突然想起来黑猴里面第四章见到二姐时的屋顶,可能最近看到黑猴的视频太多了 emmmm。

南京 7

临近中午到达了紫霞湖,许多大爷大妈们在这儿野餐和游泳。

南京 8

到达中午,终于抵达了明孝陵。

南京 9
南京 10

之后更是收集到了红粉黄橙白五种颜色的彼岸花。

南京 11

里面还有非常非常多的小园子,不再一一赘述。之后打算前往明长城看看,结果下午 5 点就关闭了。

次日去了鸡鸣寺和城墙,吾辈是从电视剧《明王朝》的视频中看到过这个,实际去了之后也没什么。

南京 12
南京 13

在城墙上散步时,看到一个很漂亮的小园子,只是忘记记下具体的位置了。

南京 14

影集

镇江

第四站是镇江,到的时候还不能办理入住,所以先去了金山寺转转。

镇江 1
镇江 2

第二天遇到了台风,天气非常非常糟糕,所以完全没有出门。第三天倒是去了附近的南山风景区,台风刚过,天气异常的好。

镇江 3
镇江 4
镇江 5

下山之后,附近的风景也非常棒,真的很喜欢这种蓝天白云的干净。旁边还有一处隐秘的日月潭,还有人在那里游泳。

镇江 6
镇江 7

晚上去了当地的历史文化街区,无甚新意,直接略过。

次日前往焦山,相比于自然风景似乎其历史文化意义更为丰富,不太感冒。

镇江 8
镇江 9

影集

无锡

好了,终于到达了最后一站无锡,刚经历过一场台风,下一场台风接踵而至,气的吾辈提前回广东了,可以说这是最糟糕的一站。

抵达当晚便前往惠山古镇,因为预报明天就开始有雨,在旅行中下雨是最烦人的天气了。

无锡 1

夜景还算不错?

无锡 2
无锡 3

探索阴暗的小巷子。

无锡 4

由于台风的影响,旁边的公园也没有开放。

无锡 5

嗯?这就结束了?
是的,由于下雨呆了一天,在下一场台风来临之前便坐上火车回去了。

影集

总结

可以说,出去旅行排解心情的效果愈发糟糕,还是需要寻找到主线任务。

在 Web 中实现一个 TypeScript Editor

2024-09-12 22:49:12

前言

最近为 Chrome 开发了可以直接在浏览器运行 TypeScript 的插件 TypeScript Console,需要将代码编辑器集成到 Chrome Devtools 面板。其实要在 Web 中引入代码编辑器也是类似的,下面分享一下如何实现。

实现

首先来看看有什么问题

  • 代码编辑器选择什么?
  • 如何在浏览器编译和运行代码?
  • 如何使用 npm 包呢?
  • 使用 npm 包怎么有类型定义提示?

了解 Monaco

首先,考虑到要编写的是 TypeScript 编辑器,所以选择 Monaco Editor。它是 VSCode 的底层编辑器,所以对 TypeScript 的支持度是毋庸置疑的。来看看如何使用它

安装依赖

1
pnpm i monaco-editor

引入它,注意 MonacoEnvironment 部分,使用 TypeScript LSP 服务需要使用 WebWorker 引入对应的语言服务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/index.ts
import './style.css'
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'

self.MonacoEnvironment = {
getWorker: (_: any, label: string) => {
if (label === 'typescript' || label === 'javascript') {
return new tsWorker()
}
return new editorWorker()
},
}

const value = `const add = (a: number, b: number) => a + b

console.log(add(1, 2))`

const editor = monaco.editor.create(document.getElementById('app')!, {
value,
language: 'typescript',
automaticLayout: true,
})
style.css
1
2
3
4
#app {
width: 100vw;
height: 100vh;
}

现在就有了一个基本的 TypeScript 编辑器了。

1727518238404.jpg

编译和运行

接下来如何编译和运行呢?编译 TypeScript 为 JavaScript 代码有多种多样的选择,包括 TypeScript、Babel、ESBuild、SWC 等等,这里考虑到性能和尺寸,选择 ESBuild,它提供 WASM 版本以在浏览器中使用。

安装依赖

1
pnpm i esbuild-wasm

基本使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { initialize, transform } from 'esbuild-wasm'
import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url'

let isInit = false
async function compileCode(code: string): Promise<string> {
if (!isInit) {
await initialize({
wasmURL: esbuildWasmUrl,
})
isInit = true
}

const result = await transform(code, {
loader: 'ts',
format: 'iife',
})
return result.code
}

console.log(
await compileCode(`const add = (a: number, b: number) => a + b

console.log(add(1, 2))`),
)

编译结果

1727506041902.jpg

接下来,如何运行编译好的代码呢?最简单的方式是直接使用 eval 执行,或者根据需要使用 WebWorker/Iframe 来运行不安全的代码。

1
2
3
4
eval(`(() => {
const add = (a, b) => a + b;
console.log(add(1, 2));
})();`)

或者也可以使用 WebWorker。

1
2
3
4
5
6
7
const code = `(() => {
const add = (a, b) => a + b;
console.log(add(1, 2));
})();`
new Worker(
URL.createObjectURL(new Blob([code], { type: 'application/javascript' })),
)

现在,结合一下上面的代码,在按下 Ctrl/Cmd+S 时触发编译执行代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// other code...

let worker: Worker
function executeCode(code: string) {
if (worker) {
worker.terminate()
}
const blobUrl = URL.createObjectURL(
new Blob([code], { type: 'application/javascript' }),
)
worker = new Worker(blobUrl)
}

window.addEventListener('keydown', async (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
const compiledCode = await compileCode(editor.getValue())
executeCode(compiledCode)
}
})

1727518280021.jpg

完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import './style.css'
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'

self.MonacoEnvironment = {
getWorker: (_: any, label: string) => {
if (label === 'typescript' || label === 'javascript') {
return new tsWorker()
}
return new editorWorker()
},
}

const value = `const add = (a: number, b: number) => a + b

console.log(add(1, 2))`

const editor = monaco.editor.create(document.getElementById('app')!, {
value,
language: 'typescript',
automaticLayout: true,
})

import { initialize, transform } from 'esbuild-wasm'
import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url'

let isInit = false
async function compileCode(code: string): Promise<string> {
if (!isInit) {
await initialize({
wasmURL: esbuildWasmUrl,
})
isInit = true
}

const result = await transform(code, {
loader: 'ts',
format: 'iife',
})
return result.code
}

let worker: Worker
function executeCode(code: string) {
if (worker) {
worker.terminate()
}
const blobUrl = URL.createObjectURL(
new Blob([code], { type: 'application/javascript' }),
)
worker = new Worker(blobUrl)
}

window.addEventListener('keydown', async (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
const compiledCode = await compileCode(editor.getValue())
executeCode(compiledCode)
}
})

引用 npm 包

接下来,应该看看如何支持引用 npm 包了。不使用构建工具时一般是怎么引用 npm 包呢?先看看来自 Preact 的 官方示例

1
2
3
4
5
6
7
8
<script type="module">
import { h, render } from 'https://esm.sh/preact'

// Create your app
const app = h('h1', null, 'Hello World!')

render(app, document.body)
</script>

可以看到,这里借助浏览器支持 ESModule 的特性,结合上 esm.sh 这个服务,便可以引用任意 npm 包。

而关键在于这里使用了 esm 格式,而上面可以看到在构建时使用了 iife 格式,简单的解决方法是将运行时的代码修改为 esm 格式,复杂的方式是将 esm 格式转换为 iife 格式。

使用 esm 格式

先说简单的方法,修改之前的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@@ -10,7 +10,7 @@ async function compileCode(code: string): Promise<string> {

const result = await transform(code, {
loader: 'ts',
- format: 'iife',
+ format: 'esm',
})
return result.code
}
@@ -23,5 +23,5 @@ function executeCode(code: string) {
const blobUrl = URL.createObjectURL(
new Blob([code], { type: 'application/javascript' }),
)
- worker = new Worker(blobUrl)
+ worker = new Worker(blobUrl, { type: 'module' })
}

现在,可以使用 esm.sh 上的 npm 包了。

1
2
3
import { sum } from 'https://esm.sh/lodash-es'

console.log(sum([1, 2, 3, 4]))

1727518362642.jpg

但实际代码中通常希望使用 import { sum } from 'lodash-es' 而非 import { sum } from 'https://esm.sh/lodash-es',所以还是需要转换 import。这涉及到操作代码语法树,此处选择使用 babel,首先安装依赖。

1
2
pnpm i @babel/standalone lodash-es
pnpm i -D @babel/types @types/babel__core @types/babel__generator @types/babel__standalone @types/babel__traverse @babel/parser @types/lodash-es

还需要给 @babel/standalone 打上类型定义的补丁(已提 PR)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/vite-env.d.ts
declare module '@babel/standalone' {
import parser from '@babel/parser'
import * as types from '@babel/types'
import type * as t from '@babel/types'
import generator from '@babel/generator'
const packages = {
parser,
types,
generator: {
default: generator,
},
}
export { packages, t }
}

然后获取所有的 import 并转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function transformImports(code: string) {
const { parser, types, generator } = packages
const ast = parser.parse(code, {
sourceType: 'module',
plugins: ['typescript'],
sourceFilename: 'example.ts',
})

const imports = ast.program.body.filter((it) => types.isImportDeclaration(it))
if (imports.length === 0) {
return code
}
imports.forEach((it) => {
it.source.value = `https://esm.sh/${it.source.value}`
})
const newCode = generator.default(ast).code
return newCode
}

然后在编译代码之前先处理一下 imports 就好了。

1
2
3
4
5
6
7
8
9
@@ -8,7 +8,7 @@ async function compileCode(code: string): Promise<string> {
isInit = true
}

- const result = await transform(code, {
+ const result = await transform(transformImports(code), {
loader: 'ts',
format: 'esm',
})

现在,编译时会自动处理 npm 模块了。

1727515167416.jpg

完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import './style.css'
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
import { groupBy } from 'lodash-es'

self.MonacoEnvironment = {
getWorker: (_: any, label: string) => {
if (label === 'typescript' || label === 'javascript') {
return new tsWorker()
}
return new editorWorker()
},
}

let value = `const add = (a: number, b: number) => a + b

console.log(add(1, 2))`

const editor = monaco.editor.create(document.getElementById('app')!, {
value,
language: 'typescript',
automaticLayout: true,
})

import { initialize, transform } from 'esbuild-wasm'
import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url'

let isInit = false
async function compileCode(code: string): Promise<string> {
if (!isInit) {
await initialize({
wasmURL: esbuildWasmUrl,
})
isInit = true
}

const result = await transform(transformImports(code), {
loader: 'ts',
format: 'esm',
})
return result.code
}

let worker: Worker
function executeCode(code: string) {
if (worker) {
worker.terminate()
}
const blobUrl = URL.createObjectURL(
new Blob([code], { type: 'application/javascript' }),
)
worker = new Worker(blobUrl, { type: 'module' })
}

import { packages } from '@babel/standalone'

function transformImports(code: string) {
const { parser, types, generator } = packages
const ast = parser.parse(code, {
sourceType: 'module',
plugins: ['typescript'],
sourceFilename: 'example.ts',
})

const imports = ast.program.body.filter((it) => types.isImportDeclaration(it))
if (imports.length === 0) {
return code
}
imports.forEach((it) => {
it.source.value = `https://esm.sh/${it.source.value}`
})
const newCode = generator.default(ast).code
return newCode
}

window.addEventListener('keydown', async (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
const compiledCode = await compileCode(editor.getValue())
executeCode(compiledCode)
}
})

使用 iife 格式

esm 是新的标准格式,但旧的 iife 仍然有一些优势。例如不挑环境、可以直接粘贴运行等,下面将演示如何将 esm 转换为 iife。

下面两段代码是等价的,但前者无法在 Devtools Console 中运行,也无法使用 eval 执行,而后者则可以。

1
2
3
4
5
6
7
// before
import { sum } from 'https://esm.sh/lodash-es'
console.log(sum([1, 2, 3, 4]))

// after
const { sum } = await import('https://esm.sh/lodash-es')
console.log(sum([1, 2, 3, 4]))

需要将下面包含 import 的代码转换为动态 import 的,参考 amd 格式可以得到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// before
import { sum } from 'lodash-es'
console.log(sum([1, 2, 3, 4]))

// after
async function define(deps: string[], fn: (...args: any[]) => any) {
const args = await Promise.all(
deps.map(async (dep) => {
const mod = await import('https://esm.sh/' + dep)
return 'default' in mod ? mod.default : mod
}),
)
return fn(...args)
}
define(['lodash-es'], ({ sum }) => {
console.log(sum([1, 2, 3, 4]))
})

接下来使用 babel 提取所有 imports 并生成一个 define 函数调用,清理所有 exports,并将自定义的 define 函数追加到顶部。

首先解析每个 import,它可能在 define 中生成多个参数,例如

1
import _, { sum } from 'lodash-es'

会得到

1
define(['lodash-es', 'lodash-es'], (_, { sum }) => {})

所以先实现解析 import

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
type ImportType = {
source: string
} & (
| {
type: 'namespace'
name: string
}
| {
type: 'default'
name: string
}
| {
type: 'named'
imports: Record<string, string>
}
)

function parseImport(imp: ImportDeclaration): ImportType[] {
const { types } = packages
const specifiers = imp.specifiers
const source = imp.source.value
const isNamespace =
specifiers.length === 1 && types.isImportNamespaceSpecifier(specifiers[0])
const includeDefault = specifiers.some((it) =>
types.isImportDefaultSpecifier(it),
)
if (isNamespace) {
return [
{
type: 'namespace',
source,
name: specifiers[0].local.name,
},
]
}
const namedImport = specifiers.filter(
(it) => !types.isImportDefaultSpecifier(it),
)
const result: ImportType[] = []
if (namedImport.length > 0) {
result.push({
type: 'named',
source,
imports: namedImport.reduce((acc, it) => {
acc[((it as ImportSpecifier).imported as Identifier).name] =
it.local.name
return acc
}, {} as Record<string, string>),
} as ImportType)
}
if (includeDefault) {
result.push({
type: 'default',
source,
name: specifiers[0].local.name,
} as ImportType)
}
return result
}

然后修改 transformImports

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
@@ -7,14 +7,69 @@ function transformImports(code: string) {
sourceFilename: 'example.ts',
})

- const imports = ast.program.body.filter((it) => types.isImportDeclaration(it))
- if (imports.length === 0) {
- return code
- }
- imports.forEach((it) => {
- it.source.value = `https://esm.sh/${it.source.value}`
- })
+ const defineAst = parser.parse(
+ `export async function define(deps: string[], fn: (...args: any[]) => any) {
+ const args = await Promise.all(
+ deps.map(async (dep) => {
+ const mod = await import('https://esm.sh/' + dep)
+ return 'default' in mod ? mod.default : mod
+ }),
+ )
+ return fn(...args)
+}
+`,
+ {
+ sourceType: 'module',
+ plugins: ['typescript'],
+ sourceFilename: 'define.ts',
+ },
+ )
+
+ const grouped = groupBy(ast.program.body, (it) => {
+ if (types.isImportDeclaration(it)) {
+ return 'import'
+ }
+ if (types.isExportDeclaration(it)) {
+ return 'export'
+ }
+ return 'other'
+ })
+ const imports = (grouped.import || []) as ImportDeclaration[]
+ const other = (grouped.other || []) as Statement[]
+ const parsedImports = imports.flatMap(parseImport)
+ const params = parsedImports.map((imp) =>
+ imp.type === 'named'
+ ? types.objectPattern(
+ Object.entries(imp.imports).map((spec) =>
+ types.objectProperty(
+ types.identifier(spec[0]),
+ types.identifier(spec[1]),
+ ),
+ ),
+ )
+ : types.identifier(imp.name),
+ )
+ const newAst = types.program([
+ defineAst.program.body[0],
+ types.expressionStatement(
+ types.callExpression(types.identifier('define'), [
+ types.arrayExpression(
+ parsedImports.map((it) => types.stringLiteral(it.source)),
+ ),
+ types.arrowFunctionExpression(params, types.blockStatement(other)),
+ ]),
+ ),
+ ])
+
+ ast.program = newAst
+
const newCode = generator.default(ast).code
return newCode
}

1727520074205.jpg

完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
import './style.css'
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
import { groupBy } from 'lodash-es'

self.MonacoEnvironment = {
getWorker: (_: any, label: string) => {
if (label === 'typescript' || label === 'javascript') {
return new tsWorker()
}
return new editorWorker()
},
}

let value = `const add = (a: number, b: number) => a + b

console.log(add(1, 2))`

const editor = monaco.editor.create(document.getElementById('app')!, {
value,
language: 'typescript',
automaticLayout: true,
})

import { initialize, transform } from 'esbuild-wasm'
import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url'

let isInit = false
async function compileCode(code: string): Promise<string> {
if (!isInit) {
await initialize({
wasmURL: esbuildWasmUrl,
})
isInit = true
}

const result = await transform(transformImports(code), {
loader: 'ts',
format: 'iife',
})
return result.code
}

let worker: Worker
function executeCode(code: string) {
if (worker) {
worker.terminate()
}
const blobUrl = URL.createObjectURL(
new Blob([code], { type: 'application/javascript' }),
)
worker = new Worker(blobUrl)
}

import { packages } from '@babel/standalone'
import type {
Identifier,
ImportDeclaration,
ImportSpecifier,
Statement,
} from '@babel/types'

type ImportType = {
source: string
} & (
| {
type: 'namespace'
name: string
}
| {
type: 'default'
name: string
}
| {
type: 'named'
imports: Record<string, string>
}
)

function parseImport(imp: ImportDeclaration): ImportType[] {
const { types } = packages
const specifiers = imp.specifiers
const source = imp.source.value
const isNamespace =
specifiers.length === 1 && types.isImportNamespaceSpecifier(specifiers[0])
const includeDefault = specifiers.some((it) =>
types.isImportDefaultSpecifier(it),
)
if (isNamespace) {
return [
{
type: 'namespace',
source,
name: specifiers[0].local.name,
},
]
}
const namedImport = specifiers.filter(
(it) => !types.isImportDefaultSpecifier(it),
)
const result: ImportType[] = []
if (namedImport.length > 0) {
result.push({
type: 'named',
source,
imports: namedImport.reduce((acc, it) => {
acc[((it as ImportSpecifier).imported as Identifier).name] =
it.local.name
return acc
}, {} as Record<string, string>),
} as ImportType)
}
if (includeDefault) {
result.push({
type: 'default',
source,
name: specifiers[0].local.name,
} as ImportType)
}
return result
}

function transformImports(code: string) {
const { parser, types, generator } = packages
const ast = parser.parse(code, {
sourceType: 'module',
plugins: ['typescript'],
sourceFilename: 'example.ts',
})

const defineAst = parser.parse(
`export async function define(deps: string[], fn: (...args: any[]) => any) {
const args = await Promise.all(
deps.map(async (dep) => {
const mod = await import('https://esm.sh/' + dep)
return 'default' in mod ? mod.default : mod
}),
)
return fn(...args)
}
`,
{
sourceType: 'module',
plugins: ['typescript'],
sourceFilename: 'define.ts',
},
)

const grouped = groupBy(ast.program.body, (it) => {
if (types.isImportDeclaration(it)) {
return 'import'
}
if (types.isExportDeclaration(it)) {
return 'export'
}
return 'other'
})
const imports = (grouped.import || []) as ImportDeclaration[]
const other = (grouped.other || []) as Statement[]
const parsedImports = imports.flatMap(parseImport)
const params = parsedImports.map((imp) =>
imp.type === 'named'
? types.objectPattern(
Object.entries(imp.imports).map((spec) =>
types.objectProperty(
types.identifier(spec[0]),
types.identifier(spec[1]),
),
),
)
: types.identifier(imp.name),
)
const newAst = types.program([
defineAst.program.body[0],
types.expressionStatement(
types.callExpression(types.identifier('define'), [
types.arrayExpression(
parsedImports.map((it) => types.stringLiteral(it.source)),
),
types.arrowFunctionExpression(params, types.blockStatement(other)),
]),
),
])

ast.program = newAst

const newCode = generator.default(ast).code
return newCode
}

window.addEventListener('keydown', async (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
const compiledCode = await compileCode(editor.getValue())
console.log(compiledCode)
executeCode(compiledCode)
}
})

处理类型定义

现在,代码可以正常编译和运行了,但在编辑器中引入的 npm 包仍然有类型错误提示,这又应当如何解决呢?

1727520833885.jpg

得益于 TypeScript 的生态发展,现在实现这个功能非常简单。首先,安装依赖

1
pnpm i @typescript/ata typescript

然后引入 @typescript/ata

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { setupTypeAcquisition } from '@typescript/ata'
import ts from 'typescript'

function initTypeAcquisition(
addLibraryToRuntime: (code: string, path: string) => void,
) {
return setupTypeAcquisition({
projectName: 'TypeScript Playground',
typescript: ts,
logger: console,
delegate: {
receivedFile: (code: string, path: string) => {
addLibraryToRuntime(code, path)
// console.log('Received file', code, path)
},
progress: (dl: number, ttl: number) => {
// console.log({ dl, ttl })
},
started: () => {
console.log('ATA start')
},
finished: (f) => {
console.log('ATA done')
},
},
})
}
const ta = initTypeAcquisition((code: string, path: string) => {
const _path = 'file://' + path
monaco.languages.typescript.typescriptDefaults.addExtraLib(code, _path)
})
editor.onDidChangeModelContent(async () => {
// 判断是否有错误
const value = editor.getValue()
await ta(value)
})
// editor 初始化完成后,执行一次 ta
ta(editor.getValue())

还需要为编辑器设置一个 Model,主要是需要指定一个虚拟文件路径让 Monaco Editor 的 TypeScript 能正确找到虚拟 node_modules 下的类型定义文件。

1
2
3
4
5
6
const model = monaco.editor.createModel(
value,
'typescript',
monaco.Uri.file('example.ts'),
)
editor.setModel(model)

1727522052191.jpg

完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
import './style.css'
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
import { groupBy } from 'lodash-es'

self.MonacoEnvironment = {
getWorker: (_: any, label: string) => {
if (label === 'typescript' || label === 'javascript') {
return new tsWorker()
}
return new editorWorker()
},
}

let value = `import { sum } from 'lodash-es'
console.log(sum([1, 2, 3, 4]))`

const editor = monaco.editor.create(document.getElementById('app')!, {
value,
language: 'typescript',
automaticLayout: true,
})

import { initialize, transform } from 'esbuild-wasm'
import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url'

let isInit = false
async function compileCode(code: string): Promise<string> {
if (!isInit) {
await initialize({
wasmURL: esbuildWasmUrl,
})
isInit = true
}

const result = await transform(transformImports(code), {
loader: 'ts',
format: 'iife',
})
return result.code
}

let worker: Worker
function executeCode(code: string) {
if (worker) {
worker.terminate()
}
const blobUrl = URL.createObjectURL(
new Blob([code], { type: 'application/javascript' }),
)
worker = new Worker(blobUrl)
}

import { packages } from '@babel/standalone'
import type {
Identifier,
ImportDeclaration,
ImportSpecifier,
Statement,
} from '@babel/types'

type ImportType = {
source: string
} & (
| {
type: 'namespace'
name: string
}
| {
type: 'default'
name: string
}
| {
type: 'named'
imports: Record<string, string>
}
)

function parseImport(imp: ImportDeclaration): ImportType[] {
const { types } = packages
const specifiers = imp.specifiers
const source = imp.source.value
const isNamespace =
specifiers.length === 1 && types.isImportNamespaceSpecifier(specifiers[0])
const includeDefault = specifiers.some((it) =>
types.isImportDefaultSpecifier(it),
)
if (isNamespace) {
return [
{
type: 'namespace',
source,
name: specifiers[0].local.name,
},
]
}
const namedImport = specifiers.filter(
(it) => !types.isImportDefaultSpecifier(it),
)
const result: ImportType[] = []
if (namedImport.length > 0) {
result.push({
type: 'named',
source,
imports: namedImport.reduce((acc, it) => {
acc[((it as ImportSpecifier).imported as Identifier).name] =
it.local.name
return acc
}, {} as Record<string, string>),
} as ImportType)
}
if (includeDefault) {
result.push({
type: 'default',
source,
name: specifiers[0].local.name,
} as ImportType)
}
return result
}

function transformImports(code: string) {
const { parser, types, generator } = packages
const ast = parser.parse(code, {
sourceType: 'module',
plugins: ['typescript'],
sourceFilename: 'example.ts',
})

const defineAst = parser.parse(
`export async function define(deps: string[], fn: (...args: any[]) => any) {
const args = await Promise.all(
deps.map(async (dep) => {
const mod = await import('https://esm.sh/' + dep)
return 'default' in mod ? mod.default : mod
}),
)
return fn(...args)
}
`,
{
sourceType: 'module',
plugins: ['typescript'],
sourceFilename: 'define.ts',
},
)

const grouped = groupBy(ast.program.body, (it) => {
if (types.isImportDeclaration(it)) {
return 'import'
}
if (types.isExportDeclaration(it)) {
return 'export'
}
return 'other'
})
const imports = (grouped.import || []) as ImportDeclaration[]
const other = (grouped.other || []) as Statement[]
const parsedImports = imports.flatMap(parseImport)
const params = parsedImports.map((imp) =>
imp.type === 'named'
? types.objectPattern(
Object.entries(imp.imports).map((spec) =>
types.objectProperty(
types.identifier(spec[0]),
types.identifier(spec[1]),
),
),
)
: types.identifier(imp.name),
)
const newAst = types.program([
defineAst.program.body[0],
types.expressionStatement(
types.callExpression(types.identifier('define'), [
types.arrayExpression(
parsedImports.map((it) => types.stringLiteral(it.source)),
),
types.arrowFunctionExpression(params, types.blockStatement(other)),
]),
),
])

ast.program = newAst

const newCode = generator.default(ast).code
return newCode
}

window.addEventListener('keydown', async (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
const compiledCode = await compileCode(editor.getValue())
console.log(compiledCode)
executeCode(compiledCode)
}
})

import { setupTypeAcquisition } from '@typescript/ata'
import ts from 'typescript'

function initTypeAcquisition(
addLibraryToRuntime: (code: string, path: string) => void,
) {
return setupTypeAcquisition({
projectName: 'TypeScript Playground',
typescript: ts,
logger: console,
delegate: {
receivedFile: (code: string, path: string) => {
addLibraryToRuntime(code, path)
// console.log('Received file', code, path)
},
progress: (dl: number, ttl: number) => {
// console.log({ dl, ttl })
},
started: () => {
console.log('ATA start')
},
finished: (f) => {
console.log('ATA done')
},
},
})
}
const ta = initTypeAcquisition((code: string, path: string) => {
const _path = 'file://' + path
monaco.languages.typescript.typescriptDefaults.addExtraLib(code, _path)
console.log('addExtraLib', _path)
})
editor.onDidChangeModelContent(async () => {
const value = editor.getValue()
await ta(value)
})
ta(editor.getValue())

const model = monaco.editor.createModel(
value,
'typescript',
monaco.Uri.file('example.ts'),
)
editor.setModel(model)

结语

上面的代码还有许多地方没有优化,例如在主线程直接编译代码可能会阻塞主线程、引入了 3 个 TypeScript 解析器导致 bundle 大小膨胀、没有正确处理 sourcemap 等等,但这仍然是一个不错的起点,可以在遇到需要为 Web 应用添加代码编辑器之时尝试用类似的方法完成。

周报 2024-08-31 -- 海陵岛之旅

2024-09-03 08:42:33

漫长的暑假终于结束,可以继续出门旅行了。这周先去了附近的海陵岛,虽然一直素有坑人的水鱼岛之称,不过吾辈还是来玩了三天。

cover

路线: 大角湾 => 大角湾夜滩 => 北洛秘境(沙滩)=> 马尾岛 => 滨海栈道 => 观海楼 => 十里银滩

大角湾

刚到这里住在了大角湾,据说是岛上最方便的地方。来的时候已经是下午,便在附近走了走,把一块海滩围起来收费并且规定只允许在那里泡水实在太蠢了。

大角湾 1

有很多人在远方冲浪。

大角湾 2

远远的还能看到左边的滨海栈道,不过今天没往那边走。

大角湾 3

大角湾夜滩

不过下午去旁边的大角湾夜滩,沙滩确实维护的还不错,海水看起来也非常清。

大角湾夜滩 1
大角湾夜滩 2
大角湾夜滩 3

街景

在步行的路上也拍了一些街景。

街景 1
街景 2
街景 3

路边还拍到了一些野花?

街景 4
街景 5

北洛秘境

在前往马尾岛的路上,偶然发现北洛秘境沙滩免费开放了,于是也便前往看了看。
这张照片的棕榈树真的有夏日海滩的感觉。

北洛秘境 1

远方的海角伫立着一座灯塔,只是不知是否还在工作。

北洛秘境 2

远方海面上依稀可见停靠的风力发电平台。

北洛秘境 3

从灯塔下面向海滩望去。

北洛秘境 4

遗憾的是,就在海滩的左侧,吾辈看到了污水排放口。

北洛秘境 5

神奇的是,污水排放口旁边似乎有个很漂亮的拍照地点,但位于岩石之上。

北洛秘境 6

马尾岛

之后前往马尾岛,看到了海面上停放着百舸千帆。

马尾岛 1

到达马尾岛入口,山脊的形状很奇怪。

马尾岛 2

此时已近黄昏,偏逢乌云汇聚。

马尾岛 3
马尾岛 4

待至七点,终于看到了美丽的余晖。

马尾岛 5
马尾岛 6
马尾岛 7

夜景

晚上吃了点东西便继续出门了,小摊夜市都陆陆续续出摊了,但大多数都还停留在十年前的骗人玩意上,套圈、打气球之类的。

夜景 1

海边可以看到涨潮已经把原本位在海边的杆子淹掉了。

夜景 2

有人在放烟花,但并没有动画中那么美好。

夜景 2

滨海栈道

第二天刚起床便有雷雨,等到下午雨终于转小时,带着相机就出了门。昨天往右走到了马尾岛,所以今天便向左出发。

沿着海滩一直向左走,便看到了城市的排污入海口,各种垃圾沿着下水道流入海中,旁边大角湾的海水水质可想而知。

大角湾 4

不消片刻,便抵达了附近的南海放生台(放生到污水入海口也活不了吧?)

大角湾 5

旁边的山上可以看到隐匿于山林之中的居所。

大角湾 6

之后就开始沿着滨海栈道出发了,总长 2.5 公里,由于栈道破损缺乏维修,花了一个半小时才走完,而且并不安全。

在一个海角可以看到部分栈道,栈道左上方便是公路。

滨海栈道 1

看起来很危险,实际上一点也不安全。

滨海栈道 2
滨海栈道 3

山林之下。

滨海栈道 4

偶遇一只鸟儿,有人知道这是什么吗?

滨海栈道 5

山上便有一座妈祖庙。

滨海栈道 6

山下则有各种大小的鹅卵石。

滨海栈道 7

又偶遇到一只世界之羊。

滨海栈道 7

微距啊微距,好想入坑微距。

滨海栈道 8

观海楼

走了很久之后终于到达山的另一侧,接近十里银滩的位置有一个楼梯,上面通往已经废弃的观海楼,随处可见废弃后的破败。

观海楼 1
观海楼 2

行至高处,海阔天空。

观海楼 3
观海楼 4

十里银滩

终于抵达山的另一侧,可以看到十里银滩了。

十里银滩 1

海边的礁石迎接着永无止境的海浪。

十里银滩 2

海天一色,有点难。

十里银滩 3

一只渔船,不知在捕捞什么东西。

十里银滩 4

下到山脚下,看到一片野沙滩,几个小孩子正在海里玩水,于是也下去想沿着沙滩走走,却不想下来时把手机忘在了台阶上。

十里银滩 5
十里银滩 6

次日中午终于前往海里泡水,并未去往收费的海滩,而是出门即达的酒店旁边的海滩。

十里银滩 7
十里银滩 8
十里银滩 9

要说海里与水上乐园泡水的不同,应该是大海有永不停歇的浪潮在推动,让人无法在一个地方安心泡水吧。

总结

关于海陵岛是否坑人,吾辈会说确实如此。虽然风景还算不错,但恐怕吾辈不会再去第二次。之后将会前往长沙,之后转向南京,随后便在江浙附近待到 9 月底了。

使用 JavaScript 创建 PoeAI 的 服务端 bot

2024-08-20 02:29:48

背景

Poe 是一个 AI 聊天机器人,它支持多种 AI 模型,包括 GPT-4o、Claude 3.5 Sonnet、Gemini Pro 等。还支持各种类型的 Bot,其中 Server Bot 是最自由的,可以自己编写 Bot 的逻辑。但是,Poe 的 Server Bot 官方仅支持 Python,而吾辈更喜欢 JavaScript,所以研究了一下怎么实现。

初始化项目

一开始使用 express 实现服务端,但后面发现 express 无法部署到 edge runtime,例如 Cloudflare Workers,所以改用 hono.js 实现。

首先使用 hono.js 创建一个项目并选择 cloudflare-workers 模板

1
2
3
pnpm create hono@latest
? Target directory hono-demo
? Which template do you want to use? cloudflare-workers

src/index.ts 是项目入口,内容如下

1
2
3
4
5
6
7
import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => c.text('Hello, world!'))

export default app

首先在 Poe 网站上创建 Server Bot,得到一个 NameAccess Key

1724235940301.jpg

协议分析

根据 Poe 协议规范,实现一个 Bot 需要实现一个特定的 post 请求,具体如下

API 接口传入的参数会有两个固定字段,type 是请求类型,version 是协议版本。

1
2
3
4
type BotRequest = {
version: string
type: 'query' | 'settings' | 'report_feedback' | 'report_error'
}

其中 query 和 settings 是必须实现的,report_feedback 和 report_error 是可选的。

下面来实现一个基本的 post 请求结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 其他代码...

app.post('/', async (c) => {
const request = await c.req.json()
const { version, type } = request
function handleQuery(c: Context) {
throw new Error('Not implemented')
}
function handleSettings(c: Context) {
throw new Error('Not implemented')
}

switch (type) {
case 'query':
return handleQuery(c)
case 'settings':
return handleSettings(c)
default:
throw new Error('Invalid request type')
}
})

Settings 请求

Settings 请求没有额外的参数,仅要求返回这个 bot 相关的一些设置。

1
2
3
4
5
6
7
8
9
interface SettingsResponse {
server_bot_dependencies?: Record<string, number> // 声明依赖的其他 bot
allow_attachments?: boolean // 是否允许附件
introduction_message?: string // 初始化消息
expand_text_attachments?: boolean // 是否扩展文本附件
enable_image_comprehension?: boolean // 是否启用图像理解,如果启用,则图片会被 Poe 解析为文本传给当前 Bot
enforce_author_role_alternation?: boolean // 是否强制交替用户/机器人角色
enable_multi_bot_chat_prompting?: boolean
}

例如,实现上面的 settings 请求,这是一个简单的响应

1
2
3
4
5
6
7
8
function handleSettings(c: Context) {
return c.json({
server_bot_dependencies: {
'GPT-4o': 1,
},
introduction_message: 'Hello, I am a server bot.',
})
}

参考 https://creator.poe.com/docs/poe-protocol-specification#settings

Query 请求

query 是关键部分,不管是请求还是响应都很复杂。

下面是请求的类型定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
type Identifier = string // Matches the identifier format described in the spec
type ContentType = 'text/markdown' | 'text/plain'
type FeedbackType = 'like' | 'dislike'
interface MessageFeedback {
type: FeedbackType
reason?: string
}
interface Attachment {
url: string
content_type: string
name: string
parsed_content?: string
}
interface ProtocolMessage {
role: 'system' | 'user' | 'bot'
content: string
content_type: ContentType
timestamp: number
message_id: Identifier
feedback: MessageFeedback[]
attachments: Attachment[]
}
interface QueryRequest {
query: ProtocolMessage[]
user_id: Identifier
conversation_id: Identifier
message_id: Identifier
access_key: string
temperature?: number
skip_system_prompt?: boolean
logit_bias?: Record<string, number>
stop_sequences?: string[]
language_code?: string
}

响应要求返回 SSE 流式响应多条消息,具体也有很多类型

meta 类型,应该返回的第一条消息,主要是用来声明一些设置

1
2
3
4
5
6
7
interface MetaMessage {
event: 'meta'
data: {
content_type?: 'text/markdown' | 'text/plain' // 内容类型,默认为 text/markdown
suggested_replies?: boolean // Poe 是否显示建议的回复,默认为 false
}
}

接下来是两种消息类型,区别只在于是否替换之前已经发送的消息

1
2
3
4
5
6
7
8
9
10
11
12
interface TextMessage {
event: 'text'
data: {
text: string
}
}
interface ReplaceMessage {
event: 'replace_response'
data: {
text: string
}
}

还有两种特殊格式的消息,一种是用来返回 JSON 数据(通常给 OpenAI 这种支持函数调用的 Bot 使用),另一种是建议回复的消息,这会出现在回复消息的下方。

1724233538152.jpg

1
2
3
4
5
6
7
8
9
10
interface JsonMessage {
event: 'json'
data: Record<string, any>
}
interface SuggestedReplyMessage {
event: 'suggested_reply'
data: {
text: string
}
}

最后是结束和错误消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
interface ErrorMessage {
event: 'error'
data: {
allow_retry: boolean
text: string
raw_response: string
error_type: string
}
}
interface DoneMessage {
event: 'done'
data: {}
}

现在来实现一个简单的 query 请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function handleQuery(c: Context) {
return streamSSE(c, async (stream) => {
const writeSSE = (
message:
| MetaMessage
| TextMessage
| ReplaceMessage
| JsonMessage
| SuggestedReplyMessage
| ErrorMessage
| DoneMessage,
) => {
stream.writeSSE({
event: message.event,
data: JSON.stringify(message.data),
})
}
writeSSE({
event: 'meta',
data: { content_type: 'text/markdown', suggested_replies: true },
})
writeSSE({ event: 'text', data: { text: 'Hello, World!' } })
writeSSE({ event: 'done', data: {} })
})
}

参考:https://creator.poe.com/docs/poe-protocol-specification#query

发布到 Cloudflare Workers

现在发布到 Cloudflare Workers 上,得到一个 URL,例如 https://xxx.workers.dev

1
pnpm run deploy

现在在 Poe 网站上填写 Server URL,然后点击 Run check,如果成功,继续创建 Bot 就可以在 Poe 上使用了。

1724236097284.jpg
1724236179345.jpg

验证请求

根据 Poe 官方的建议,还应该为 post 请求添加验证,确定是来自 Poe 的请求。首先添加环境变量

1
2
3
ACCESS_KEY="<YOUR_ACCESS_KEY>"
echo ACCESS_KEY=\"$ACCESS_KEY\" > .dev.vars # 在本地添加
echo $ACCESS_KEY | pnpm wrangler secret put ACCESS_KEY # 在生产环境中添加

然后在 src/index.ts 中添加验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Bindings = {
ACCESS_KEY: string
}
// 声明环境变量绑定
const app = new Hono<{ Bindings: Bindings }>()

app.post('/', async (c) => {
const request = await c.req.json()
const authHeader = c.req.header().authorization
if (authHeader !== `Bearer ${c.env.ACCESS_KEY}`) {
return c.text('Unauthorized', 401)
}
// 其他代码...
})

主动调用

Bot 除了可以被 Poe 调用,也可以主动调用 Poe 的 API 来实现一些功能,下面介绍其中两个。

刷新 Bot Settings

修改 Bot Settings 的实现后,还需要主动通知 Poe 调用接口刷新设置。

例如修改了 handleSettings 函数,更新了 server_bot_dependencies,不再使用 GPT-4o,而是使用 Claude-3.5-Sonnet

1
2
3
4
5
6
7
function handleSettings(c: Context) {
return c.json({
server_bot_dependencies: {
'Claude-3.5-Sonnet': 1,
},
})
}

然后在项目初始化的时候主动通知 Poe 刷新设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function syncBotSettings(
botName: string,
accessKey: string = '',
): Promise<void> {
const PROTOCOL_VERSION = '1.0'
const baseUrl = 'https://api.poe.com/bot/fetch_settings'
const resp = await fetch(
`${baseUrl}/${botName}/${accessKey}/${PROTOCOL_VERSION}`,
{ method: 'post' },
)
const text = await resp.text()
if (!resp.ok) {
throw new Error(`Error fetching settings for bot ${botName}: ${text}`)
}
console.log(text)
}

app.get('/sync-bot-settings', async (c) => {
await syncBotSettings('BotT6R4NKNGZ9', c.env.ACCESS_KEY)
return c.text('Synced')
})

然后在浏览器中直接访问这个 URL 就可以通知 Poe 刷新设置了。

参考:https://creator.poe.com/docs/server-bots-functional-guides#3-make-a-post-request-to-poes-refetch-settings-endpoint-with-your-bot-name-and-access-key

调用其他 Bot

接下来,说明如何调用第三方的 Bot,这里仅以文本 => 文本的 Bot 为例(除此之外,现在的 Bot 还支持附件文件、图片、音视频等)。遗憾的是,官方文档没有记录 API 接口,只说明了使用 python 模块 fastapi_poe 来实现,所以只能分析 fastapi_poe 的源码来实现。

在其中可以找到关键接口 https://api.poe.com/bot/<botName>,然后接口会以 SSE 流式响应多条消息,先做个测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
async function* requestStream(
request: QueryRequest,
botName: string,
accessKey: string,
): AsyncGenerator<{
event: 'text'
data: any
}> {
// region 解析 SSE 流
const response = await fetch(`https://api.poe.com/bot/${botName}`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessKey}`,
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
},
body: JSON.stringify(request),
})
if (!response.ok || !response.body) {
console.error(response.statusText)
throw new Error(`HTTP error! status: ${response.status}`)
}
const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader()
let chunk = await reader.read()
while (!chunk.done) {
console.log('chunk: ', chunk.value)
chunk = await reader.read()
}
// endregion
}

function handleQuery(c: Context<{ Bindings: Bindings }>) {
return streamSSE(c, async (stream) => {
const writeSSE = (
message:
| MetaMessage
| TextMessage
| ReplaceMessage
| JsonMessage
| SuggestedReplyMessage
| ErrorMessage
| DoneMessage,
) => {
stream.writeSSE({
event: message.event,
data: JSON.stringify(message.data),
})
}
writeSSE({
event: 'meta',
data: { content_type: 'text/markdown', suggested_replies: true },
})
for await (const chunk of requestStream(
request,
'GPT-4o',
c.env.ACCESS_KEY,
)) {
if (chunk.event === 'text') {
writeSSE(chunk)
}
}
writeSSE({ event: 'done', data: {} })
})
}

终端打印的结果。可以消息分为三种:

  • text: 普通文本消息,但可能会被拆分成多个 chunk 返回
  • done: 结束消息
  • 空消息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
chunk:  event: text
data: {"text":
chunk: ""}

event: text
data: {"text": "Hello"}
chunk: event: text
data: {"text":
chunk: "!"}

event: text
data: {"text": " How"}
chunk: event: text
data: {"text":
chunk: " can"}

event: text
data: {"text": " I"}
chunk: event: text
data: {"text":
chunk: " assist"}

event: text
data: {"text": " you"}
chunk: event: text
data: {"text":
chunk: " today"}

event: text
data: {"text": "?"}
chunk: event: text
data: {"text":
chunk: ""}
chunk: event: done
data: {}
chunk:

因此需要实现一个 TransformStream 来将 SSE 文本流转换为结构化的数据,并处理 text 多条消息合并。实现本身并不复杂,但主要的问题是多个模型拆分规则可能规则会不一致,例如 GPT-4o chunk 中可能包含一条完整消息,也可能不包含,而 Claude 3.5 Sonnet 中则总是由两个 chunk 组成一条完整消息。还有一些模型会返回 ping 消息,而且 ping 消息的格式也略有不同,像是 ping: ping 等。

下面是 GPT-4o 的消息示例

1
2
3
4
;[
'event: text\r\ndata: {"text": ',
'""}\r\n\r\nevent: text\r\ndata: {"text": "Hi"}\r\n\r\n',
]

Claude 3.5 Sonnet 的消息示例

1
;['event: text\r\ndata: {"text": ', '"Hello"}\r\n\r\n']

所以实现的 TransformStream 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
function sseTransformStream() {
let buffer = ''
return new TransformStream<
string,
{
event: 'text'
data: any
}
>({
transform(chunk: string, controller: TransformStreamDefaultController) {
buffer += chunk
const textRegexp = /^event: text[\r\n]+data: ({[\s\S]*?})/
const doneRegexp = /^event: done[\r\n]+data: ({})/
while (buffer) {
// console.log(
// !/^ping$/m.test(buffer) &&
// !doneRegexp.test(buffer) &&
// !textRegexp.test(buffer) &&
// buffer !== '' &&
// buffer !== 'event: text\r\ndata: {"text": ' &&
// buffer.trim() !== ': ping',
// )
buffer = buffer.trimStart()
if (textRegexp.test(buffer)) {
const match = buffer.match(textRegexp)
if (match) {
controller.enqueue({
event: 'text',
data: JSON.parse(match[1]),
})
buffer = buffer.replace(match[0], '').trimStart()
}
} else if (doneRegexp.test(buffer)) {
const match = buffer.match(doneRegexp)
if (match) {
// controller.enqueue({
// event: 'done',
// data: JSON.parse(match[1]),
// })
buffer = buffer.replace(match[0], '').trimStart()
controller.terminate()
return
}
// ignore ping
} else if (/^ping$/m.test(buffer)) {
const match = buffer.match(/^ping$/m)
if (match) {
buffer = buffer.replace(match[0], '').trimStart()
}
} else if (buffer.trim() === ': ping') {
buffer = buffer.replace(': ping', '').trimStart()
} else {
return
}
}
},
flush() {
if (buffer.trim()) {
console.warn('Unprocessed data in buffer:', buffer)
}
},
})
}

修改 requestStream 来使用这个 TransformStream

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async function* requestStream(
request: QueryRequest,
botName: string,
accessKey: string,
): AsyncGenerator<string> {
// 其他代码...
const reader = response
.body!.pipeThrough(new TextDecoderStream())
.pipeThrough(sseTransformStream())
.getReader()
let chunk = await reader.read()
while (!chunk.done) {
yield chunk.value
chunk = await reader.read()
}
}

完整代码

完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
import { Context, Hono } from 'hono'
import { streamSSE } from 'hono/streaming'

interface SettingsResponse {
server_bot_dependencies?: Record<string, number> // 声明依赖的其他 bot
allow_attachments?: boolean // 是否允许附件
introduction_message?: string // 初始化消息
expand_text_attachments?: boolean // 是否扩展文本附件
enable_image_comprehension?: boolean // 是否启用图像理解,如果启用,则图片会被 Poe 解析为文本传给当前 Bot
enforce_author_role_alternation?: boolean // 是否强制交替用户/机器人角色
enable_multi_bot_chat_prompting?: boolean
}

type Identifier = string // Matches the identifier format described in the spec
type ContentType = 'text/markdown' | 'text/plain'
type FeedbackType = 'like' | 'dislike'
interface MessageFeedback {
type: FeedbackType
reason?: string
}
interface Attachment {
url: string
content_type: string
name: string
parsed_content?: string
}
interface ProtocolMessage {
role: 'system' | 'user' | 'bot'
content: string
content_type: ContentType
timestamp: number
message_id: Identifier
feedback: MessageFeedback[]
attachments: Attachment[]
}
interface QueryRequest {
query: ProtocolMessage[]
user_id: Identifier
conversation_id: Identifier
message_id: Identifier
access_key: string
temperature?: number
skip_system_prompt?: boolean
logit_bias?: Record<string, number>
stop_sequences?: string[]
language_code?: string
}
interface MetaMessage {
event: 'meta'
data: {
content_type?: 'text/markdown' | 'text/plain' // 内容类型,默认为 text/markdown
suggested_replies?: boolean // Poe 是否显示建议的回复,默认为 false
}
}
interface TextMessage {
event: 'text'
data: {
text: string
}
}
interface ReplaceMessage {
event: 'replace_response'
data: {
text: string
}
}
interface JsonMessage {
event: 'json'
data: Record<string, any>
}
interface SuggestedReplyMessage {
event: 'suggested_reply'
data: {
text: string
}
}
interface ErrorMessage {
event: 'error'
data: {
allow_retry: boolean
text: string
raw_response: string
error_type: string
}
}
interface DoneMessage {
event: 'done'
data: {}
}

type Bindings = {
ACCESS_KEY: string
}

const app = new Hono<{ Bindings: Bindings }>()

app.get('/', (c) => c.text('Hello, World!'))
app.post('/', async (c) => {
const request = await c.req.json()
const authHeader = c.req.header().authorization
if (authHeader !== `Bearer ${c.env.ACCESS_KEY}`) {
return c.text('Unauthorized', 401)
}
const { type } = request
function handleSettings(c: Context) {
return c.json({
server_bot_dependencies: {
'GPT-4o': 1,
},
introduction_message: 'Hello, I am a server bot.',
} as SettingsResponse)
}
function handleQuery(c: Context<{ Bindings: Bindings }>) {
return streamSSE(c, async (stream) => {
const writeSSE = (
message:
| MetaMessage
| TextMessage
| ReplaceMessage
| JsonMessage
| SuggestedReplyMessage
| ErrorMessage
| DoneMessage,
) => {
stream.writeSSE({
event: message.event,
data: JSON.stringify(message.data),
})
}
writeSSE({
event: 'meta',
data: { content_type: 'text/markdown', suggested_replies: true },
})
for await (const chunk of requestStream(
request,
'GPT-4o',
c.env.ACCESS_KEY,
)) {
if (chunk.event === 'text') {
writeSSE(chunk)
}
}
// writeSSE({ event: 'text', data: { text: 'Hello, World!' } })
writeSSE({ event: 'done', data: {} })
})
}
switch (type) {
case 'query':
return handleQuery(c)
case 'settings':
return handleSettings(c)
default:
throw new Error('Invalid request type')
}
})

function sseTransformStream() {
let buffer = ''
return new TransformStream<
string,
{
event: 'text'
data: any
}
>({
transform(chunk: string, controller: TransformStreamDefaultController) {
buffer += chunk
const textRegexp = /^event: text[\r\n]+data: ({[\s\S]*?})/
const doneRegexp = /^event: done[\r\n]+data: ({})/
while (buffer) {
// console.log(
// !/^ping$/m.test(buffer) &&
// !doneRegexp.test(buffer) &&
// !textRegexp.test(buffer) &&
// buffer !== '' &&
// buffer !== 'event: text\r\ndata: {"text": ' &&
// buffer.trim() !== ': ping',
// )
buffer = buffer.trimStart()
if (textRegexp.test(buffer)) {
const match = buffer.match(textRegexp)
if (match) {
controller.enqueue({
event: 'text',
data: JSON.parse(match[1]),
})
buffer = buffer.replace(match[0], '').trimStart()
}
} else if (doneRegexp.test(buffer)) {
const match = buffer.match(doneRegexp)
if (match) {
// controller.enqueue({
// event: 'done',
// data: JSON.parse(match[1]),
// })
buffer = buffer.replace(match[0], '').trimStart()
controller.terminate()
return
}
// ignore ping
} else if (/^ping$/m.test(buffer)) {
const match = buffer.match(/^ping$/m)
if (match) {
buffer = buffer.replace(match[0], '').trimStart()
}
} else if (buffer.trim() === ': ping') {
buffer = buffer.replace(': ping', '').trimStart()
} else {
return
}
}
},
flush() {
if (buffer.trim()) {
console.warn('Unprocessed data in buffer:', buffer)
}
},
})
}

async function* requestStream(
request: QueryRequest,
botName: string,
accessKey: string,
): AsyncGenerator<{
event: 'text'
data: any
}> {
const response = await fetch(`https://api.poe.com/bot/${botName}`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessKey}`,
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
},
body: JSON.stringify(request),
})
if (!response.ok || !response.body) {
console.error(response.statusText)
throw new Error(`HTTP error! status: ${response.status}`)
}
const reader = response
.body!.pipeThrough(new TextDecoderStream())
.pipeThrough(sseTransformStream())
.getReader()
let chunk = await reader.read()
while (!chunk.done) {
yield chunk.value
chunk = await reader.read()
}
}

async function syncBotSettings(
botName: string,
accessKey: string = '',
): Promise<void> {
const PROTOCOL_VERSION = '1.0'
const baseUrl = 'https://api.poe.com/bot/fetch_settings'
const resp = await fetch(
`${baseUrl}/${botName}/${accessKey}/${PROTOCOL_VERSION}`,
{ method: 'post' },
)
const text = await resp.text()
if (!resp.ok) {
throw new Error(`Error fetching settings for bot ${botName}: ${text}`)
}
console.log(text)
}

app.get('/sync-bot-settings', async (c) => {
await syncBotSettings('BotT6R4NKNGZ9', c.env.ACCESS_KEY)
return c.text('Synced')
})

export default app

现在,重新发布至 Cloudflare Workers,然后就可以向这个 Bot 聊天,并在服务端调用 GPT-4o 模型。

1724255995710.jpg

总结

上面只是一个非常简单的 demo,Poe Server Bot 实际上还可以做很多事情,但对 JavaScript 缺乏官方支持,让想要尝试变得比较麻烦。吾辈发布了一个 npm 模块 fastapi-poe,来尝试像官方的 python 模块 fastapi_poe 一样使用。