feat: introduce mnie

This commit is contained in:
Shotaro Nakamura
2026-06-21 21:37:07 +09:00
parent 614ea8a992
commit fed3ac1b61
104 changed files with 1815 additions and 218 deletions
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@repo/mnie-sdk",
"private": true,
"type": "module",
"module": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"clean": "rm -rf dist",
"typecheck": "tsc"
},
"dependencies": {
"@repo/mnie-types": "workspace:*"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}
+113
View File
@@ -0,0 +1,113 @@
import type { SbiClientMethods } from '@repo/mnie-types'
export type MnieSdkOptions = {
origin: string
apiKey: string
WebSocket?: typeof WebSocket
}
export type JsonRpcError = {
code: number
message: string
}
export type MnieClient = SbiClientMethods & {
connect(options: { passkeyId: string }): Promise<{ connected: true; passkeyId: string }>
methods(): Promise<string[]>
close(): void
}
type JsonRpcResponse = {
jsonrpc?: '2.0'
id?: string | number | null
result?: unknown
error?: JsonRpcError
}
type PendingCall = {
resolve: (value: unknown) => void
reject: (error: Error) => void
}
const websocketUrl = (origin: string, apiKey: string) => {
const url = new URL('/api/ws', origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.searchParams.set('key', apiKey)
return url.toString()
}
class MnieJsonRpcClient {
readonly #socket: WebSocket
readonly #pending = new Map<string, PendingCall>()
#nextId = 1
#opened: Promise<void>
constructor(options: MnieSdkOptions) {
const WebSocketCtor = options.WebSocket ?? WebSocket
this.#socket = new WebSocketCtor(websocketUrl(options.origin, options.apiKey))
this.#opened = new Promise((resolve, reject) => {
this.#socket.addEventListener('open', () => resolve(), { once: true })
this.#socket.addEventListener(
'error',
() => reject(new Error('failed to connect to Mnie RPC')),
{ once: true },
)
})
this.#socket.addEventListener('message', (event) => this.#handleMessage(event))
this.#socket.addEventListener('close', () => this.#rejectPending('Mnie RPC socket closed'))
}
close() {
this.#socket.close()
}
async call(method: string, params?: unknown) {
await this.#opened
const id = String(this.#nextId++)
const response = new Promise((resolve, reject) => {
this.#pending.set(id, { resolve, reject })
})
this.#socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }))
return response
}
#handleMessage(event: MessageEvent) {
const message = JSON.parse(String(event.data)) as JsonRpcResponse
if (message.id == null) return
const pending = this.#pending.get(String(message.id))
if (!pending) return
this.#pending.delete(String(message.id))
if (message.error) {
pending.reject(new Error(message.error.message))
return
}
pending.resolve(message.result)
}
#rejectPending(message: string) {
for (const pending of this.#pending.values()) pending.reject(new Error(message))
this.#pending.clear()
}
}
const methodProxy = (rpc: MnieJsonRpcClient, path: string[] = []): unknown =>
new Proxy(() => undefined, {
get(_target, property) {
if (property === 'then') return undefined
if (property === 'connect') {
return (params: { passkeyId: string }) => rpc.call('sbi.connect', params)
}
if (property === 'methods') return () => rpc.call('rpc.methods')
if (property === 'close') return () => rpc.close()
if (typeof property !== 'string') return undefined
return methodProxy(rpc, [...path, property])
},
apply(_target, _thisArg, args) {
return rpc.call(path.join('.'), args.length > 1 ? args : args[0])
},
})
export const createMnieClient = (options: MnieSdkOptions) =>
methodProxy(new MnieJsonRpcClient(options)) as MnieClient
export type * from '@repo/mnie-types'
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*.ts"]
}