feat: import web relay worker from monorepo
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
1
migrate-add-avatar-id.sql
Normal file
1
migrate-add-avatar-id.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "apikey" ADD COLUMN "avatar_id" TEXT REFERENCES "avatars"("id") ON DELETE SET NULL;
|
||||
3
migrate-add-emoji-trigger-fields.sql
Normal file
3
migrate-add-emoji-trigger-fields.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "emojis" ADD COLUMN "trigger_type" TEXT NOT NULL DEFAULT 'message';
|
||||
ALTER TABLE "emojis" ADD COLUMN "trigger_result" TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "emojis_avatar_default_unique_idx" ON "emojis" ("avatar_id") WHERE "trigger_type" = 'default';
|
||||
14
migrate-add-user-file.sql
Normal file
14
migrate-add-user-file.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE IF NOT EXISTS "user_file" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"filename" TEXT NOT NULL,
|
||||
"original_name" TEXT NOT NULL,
|
||||
"content_type" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"s3_key" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"uploaded_at" INTEGER NOT NULL,
|
||||
FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "user_file_user_id_idx" ON "user_file" ("user_id");
|
||||
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@sker-pro/web-relay",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev --ip=0.0.0.0",
|
||||
"deploy": "wrangler deploy",
|
||||
"deploy:prod": "wrangler deploy --env prod",
|
||||
"types": "wrangler types --env-interface CloudflareBindings --env dev",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:init:local": "wrangler d1 execute clawshow-db --local --file=./schema.sql",
|
||||
"db:init:remote": "wrangler d1 execute clawshow-db --remote --file=./schema.sql",
|
||||
"db:migrate:add-avatar-id:local": "wrangler d1 execute clawshow-db --local --file=./migrate-add-avatar-id.sql",
|
||||
"db:migrate:add-avatar-id:remote": "wrangler d1 execute clawshow-db --remote --file=./migrate-add-avatar-id.sql",
|
||||
"db:migrate:add-user-file:local": "wrangler d1 execute clawshow-db --local --file=./migrate-add-user-file.sql",
|
||||
"db:migrate:add-user-file:remote": "wrangler d1 execute clawshow-db --remote --file=./migrate-add-user-file.sql",
|
||||
"db:migrate:add-emoji-trigger-fields:local": "powershell -ExecutionPolicy Bypass -File ./migrate-add-emoji-trigger-fields.ps1",
|
||||
"db:migrate:add-emoji-trigger-fields:remote": "powershell -ExecutionPolicy Bypass -File ./migrate-add-emoji-trigger-fields.ps1 -Remote"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/api-key": "^1.2.0",
|
||||
"@bowong/auth": "git+https://gitea.bowongai.com/bowong/claushow-auth.git#main",
|
||||
"better-auth": "^1.2.0",
|
||||
"drizzle-orm": "^0.44.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250312.0",
|
||||
"wrangler": "^4.14.0"
|
||||
}
|
||||
}
|
||||
132
schema.sql
Normal file
132
schema.sql
Normal file
@@ -0,0 +1,132 @@
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"name" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"email_verified" INTEGER NOT NULL DEFAULT 0,
|
||||
"image" TEXT,
|
||||
"created_at" INTEGER NOT NULL,
|
||||
"updated_at" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "user_email_unique" ON "user" ("email");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "session" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"expires_at" INTEGER NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"created_at" INTEGER NOT NULL,
|
||||
"updated_at" INTEGER NOT NULL,
|
||||
"ip_address" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"user_id" TEXT NOT NULL,
|
||||
FOREIGN KEY ("user_id") REFERENCES "user"("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "session_token_unique" ON "session" ("token");
|
||||
CREATE INDEX IF NOT EXISTS "session_user_id_idx" ON "session" ("user_id");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "account" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"account_id" TEXT NOT NULL,
|
||||
"provider_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"access_token" TEXT,
|
||||
"refresh_token" TEXT,
|
||||
"id_token" TEXT,
|
||||
"access_token_expires_at" INTEGER,
|
||||
"refresh_token_expires_at" INTEGER,
|
||||
"scope" TEXT,
|
||||
"password" TEXT,
|
||||
"created_at" INTEGER NOT NULL,
|
||||
"updated_at" INTEGER NOT NULL,
|
||||
FOREIGN KEY ("user_id") REFERENCES "user"("id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "account_user_id_idx" ON "account" ("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "account_provider_account_idx" ON "account" ("provider_id", "account_id");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "verification" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"expires_at" INTEGER NOT NULL,
|
||||
"created_at" INTEGER,
|
||||
"updated_at" INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "verification_identifier_idx" ON "verification" ("identifier");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "apikey" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"config_id" TEXT,
|
||||
"name" TEXT,
|
||||
"start" TEXT,
|
||||
"prefix" TEXT,
|
||||
"key" TEXT NOT NULL,
|
||||
"reference_id" TEXT,
|
||||
"enabled" INTEGER DEFAULT 1,
|
||||
"rate_limit_enabled" INTEGER DEFAULT 1,
|
||||
"rate_limit_time_window" INTEGER,
|
||||
"rate_limit_max" INTEGER,
|
||||
"request_count" INTEGER DEFAULT 0,
|
||||
"remaining" INTEGER,
|
||||
"refill_interval" INTEGER,
|
||||
"refill_amount" INTEGER,
|
||||
"last_refill_at" INTEGER,
|
||||
"last_request" INTEGER,
|
||||
"permissions" TEXT,
|
||||
"metadata" TEXT,
|
||||
"expires_at" INTEGER,
|
||||
"created_at" INTEGER NOT NULL,
|
||||
"updated_at" INTEGER NOT NULL,
|
||||
"avatar_id" TEXT,
|
||||
FOREIGN KEY ("avatar_id") REFERENCES "avatars"("id") ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "apikey_reference_id_idx" ON "apikey" ("reference_id");
|
||||
CREATE INDEX IF NOT EXISTS "apikey_key_idx" ON "apikey" ("key");
|
||||
|
||||
-- 形象/表情包集合表
|
||||
CREATE TABLE IF NOT EXISTS "avatars" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"created_at" INTEGER NOT NULL,
|
||||
"updated_at" INTEGER NOT NULL,
|
||||
FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "avatars_user_id_idx" ON "avatars" ("user_id");
|
||||
|
||||
-- 表情表
|
||||
CREATE TABLE IF NOT EXISTS "emojis" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"avatar_id" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"trigger_type" TEXT NOT NULL DEFAULT 'message',
|
||||
"trigger_result" TEXT,
|
||||
"sort_order" INTEGER DEFAULT 0,
|
||||
"created_at" INTEGER NOT NULL,
|
||||
FOREIGN KEY ("avatar_id") REFERENCES "avatars"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "emojis_avatar_id_idx" ON "emojis" ("avatar_id");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "emojis_avatar_default_unique_idx" ON "emojis" ("avatar_id") WHERE "trigger_type" = 'default';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "user_file" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"filename" TEXT NOT NULL,
|
||||
"original_name" TEXT NOT NULL,
|
||||
"content_type" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"s3_key" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"uploaded_at" INTEGER NOT NULL,
|
||||
FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "user_file_user_id_idx" ON "user_file" ("user_id");
|
||||
58
src/auth.ts
Normal file
58
src/auth.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Authentication utilities for the WebSocket relay.
|
||||
*/
|
||||
|
||||
import type { Auth } from '@bowong/auth'
|
||||
|
||||
/**
|
||||
* Validate a Better Auth bearer token by querying D1 directly.
|
||||
* Returns user info if valid, null otherwise.
|
||||
*/
|
||||
export async function validateBearerToken(
|
||||
db: D1Database,
|
||||
token: string,
|
||||
): Promise<{ userId: string; userName: string | null } | null> {
|
||||
const result = await db
|
||||
.prepare(
|
||||
`SELECT s.user_id, u.name FROM session s JOIN user u ON s.user_id = u.id WHERE s.token = ? AND s.expires_at > ?`,
|
||||
)
|
||||
.bind(token, Math.floor(Date.now() / 1000))
|
||||
.first<{ user_id: string; name: string | null }>()
|
||||
|
||||
if (!result) return null
|
||||
return { userId: result.user_id, userName: result.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an API key (lobster identity) via Better Auth api-key plugin.
|
||||
*/
|
||||
export async function verifyApiKeyToken(
|
||||
auth: Auth,
|
||||
apiKeyValue: string,
|
||||
): Promise<{
|
||||
valid: boolean
|
||||
keyId?: string
|
||||
keyName?: string
|
||||
userId?: string
|
||||
metadata?: Record<string, unknown>
|
||||
} | null> {
|
||||
try {
|
||||
const result = await auth.api.verifyApiKey({
|
||||
body: { key: apiKeyValue },
|
||||
})
|
||||
if (!result?.valid || !result.key) return { valid: false }
|
||||
return {
|
||||
valid: true,
|
||||
keyId: result.key.id,
|
||||
keyName: result.key.name ?? undefined,
|
||||
userId: result.key.referenceId ?? undefined,
|
||||
metadata:
|
||||
typeof result.key.metadata === 'string'
|
||||
? JSON.parse(result.key.metadata)
|
||||
: (result.key.metadata ?? undefined),
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[auth] API key verification failed:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
1536
src/chat-relay.ts
Normal file
1536
src/chat-relay.ts
Normal file
File diff suppressed because it is too large
Load Diff
216
src/index.ts
Normal file
216
src/index.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Cloudflare Worker entry point.
|
||||
* - /api/auth/* → Better Auth handler (sign-up, sign-in, session, etc.)
|
||||
* - /api/auth/avatars/* → Avatar management plugin
|
||||
* - /api/auth/emojis/* → Emoji management plugin
|
||||
* - /api/auth/apikeys/* → API key avatar binding plugin
|
||||
* - /ws, /openclaw, /health → ChatRelay Durable Object
|
||||
*/
|
||||
|
||||
import { createAuth } from '@bowong/auth'
|
||||
|
||||
export { ChatRelay } from './chat-relay.js'
|
||||
|
||||
interface Env {
|
||||
CHAT_RELAY: DurableObjectNamespace
|
||||
AUTH_DB: D1Database
|
||||
BETTER_AUTH_SECRET: string
|
||||
RELAY_URL?: string
|
||||
ALLOWED_ORIGINS?: string
|
||||
AWS_ACCESS_KEY_ID?: string
|
||||
AWS_SECRET_ACCESS_KEY?: string
|
||||
AWS_BUCKET_NAME?: string
|
||||
AWS_LOCATION?: string
|
||||
AWS_ENDPOINT?: string
|
||||
AWS_PUBLIC_BASE_URL?: string
|
||||
AWS_KEY_PREFIX?: string
|
||||
}
|
||||
|
||||
function createAuthInstance(env: Env, url: URL) {
|
||||
return createAuth({
|
||||
db: env.AUTH_DB,
|
||||
baseURL: env.RELAY_URL ?? url.origin,
|
||||
secret: env.BETTER_AUTH_SECRET || `It3546porQSYeKhQyplQRRjmohE5AmR6rF4WoKq85t0=`,
|
||||
trustedOrigins: env.ALLOWED_ORIGINS ? env.ALLOWED_ORIGINS.split(',') : undefined,
|
||||
s3: env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY && env.AWS_BUCKET_NAME && env.AWS_LOCATION
|
||||
? {
|
||||
credentials: {
|
||||
accessKeyId: env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
|
||||
bucketName: env.AWS_BUCKET_NAME,
|
||||
region: env.AWS_LOCATION,
|
||||
endpoint: env.AWS_ENDPOINT,
|
||||
publicBaseUrl: env.AWS_PUBLIC_BASE_URL ?? 'https://cdn.bowong.cc',
|
||||
keyPrefix: env.AWS_KEY_PREFIX ?? 'emoji-files',
|
||||
},
|
||||
allowedTypes: ['webm', 'mp4', 'mov', 'png', 'jpg', 'jpeg', 'gif'],
|
||||
maxFileSize: 25 * 1024 * 1024,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function rewriteRequestPath(request: Request, pathname: string): Request {
|
||||
const nextUrl = new URL(request.url)
|
||||
nextUrl.pathname = pathname
|
||||
return new Request(nextUrl.toString(), request)
|
||||
}
|
||||
|
||||
function getConfiguredAllowedOrigins(env: Env): string[] {
|
||||
if (!env.ALLOWED_ORIGINS) return []
|
||||
return env.ALLOWED_ORIGINS.split(',')
|
||||
.map(o => o.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function getAllowedOrigins(request: Request, env: Env): string[] {
|
||||
const configured = getConfiguredAllowedOrigins(env)
|
||||
if (configured.length > 0) {
|
||||
return configured
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const origins = new Set<string>([url.origin])
|
||||
const requestOrigin = request.headers.get('Origin')
|
||||
if (requestOrigin) {
|
||||
origins.add(requestOrigin)
|
||||
}
|
||||
return [...origins]
|
||||
}
|
||||
|
||||
function corsHeaders(request: Request, env: Env): Record<string, string> {
|
||||
const origin = request.headers.get('Origin') ?? ''
|
||||
const allowed = getAllowedOrigins(request, env)
|
||||
const isAllowed = origin && allowed.includes(origin)
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Better-Auth-Token, x-filename, x-file-metadata',
|
||||
'Access-Control-Expose-Headers': 'set-auth-token',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
}
|
||||
|
||||
// When credentials are involved, we MUST return the exact origin, not wildcard
|
||||
if (origin && isAllowed) {
|
||||
headers['Access-Control-Allow-Origin'] = origin
|
||||
headers['Access-Control-Allow-Credentials'] = 'true'
|
||||
} else if (!origin) {
|
||||
headers['Access-Control-Allow-Origin'] = '*'
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
function addCorsHeaders(
|
||||
response: Response,
|
||||
request: Request,
|
||||
env: Env,
|
||||
): Response {
|
||||
const origin = request.headers.get('Origin') ?? ''
|
||||
const allowedOrigins = getAllowedOrigins(request, env)
|
||||
const isAllowedOrigin = origin && allowedOrigins.includes(origin)
|
||||
|
||||
const newHeaders = new Headers()
|
||||
|
||||
// Copy non-CORS headers from the original response
|
||||
for (const [key, value] of response.headers.entries()) {
|
||||
// Skip ALL CORS-related headers from Better Auth
|
||||
if (key.toLowerCase() !== 'access-control-allow-origin' &&
|
||||
key.toLowerCase() !== 'access-control-allow-methods' &&
|
||||
key.toLowerCase() !== 'access-control-allow-headers' &&
|
||||
key.toLowerCase() !== 'access-control-allow-credentials' &&
|
||||
key.toLowerCase() !== 'access-control-expose-headers' &&
|
||||
key.toLowerCase() !== 'access-control-max-age') {
|
||||
newHeaders.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Set our own CORS headers
|
||||
// When credentials are involved, we MUST return the exact origin, not wildcard
|
||||
if (origin && isAllowedOrigin) {
|
||||
newHeaders.set('Access-Control-Allow-Origin', origin)
|
||||
newHeaders.set('Access-Control-Allow-Credentials', 'true')
|
||||
} else if (!origin) {
|
||||
// No Origin header (same-origin or non-browser request) - allow all
|
||||
newHeaders.set('Access-Control-Allow-Origin', '*')
|
||||
}
|
||||
newHeaders.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||
newHeaders.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Better-Auth-Token, x-filename, x-file-metadata')
|
||||
newHeaders.set('Access-Control-Expose-Headers', 'set-auth-token')
|
||||
newHeaders.set('Access-Control-Max-Age', '86400')
|
||||
newHeaders.append('Vary', 'Origin')
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: newHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
|
||||
// CORS preflight
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: corsHeaders(request, env),
|
||||
})
|
||||
}
|
||||
|
||||
// Health check
|
||||
if (url.pathname === '/health') {
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...corsHeaders(request, env),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname === '/docs') {
|
||||
const auth = createAuthInstance(env, url)
|
||||
const response = await auth.handler(
|
||||
rewriteRequestPath(request, '/api/auth/reference'),
|
||||
)
|
||||
return addCorsHeaders(response, request, env)
|
||||
}
|
||||
|
||||
if (url.pathname === '/openapi.json') {
|
||||
const auth = createAuthInstance(env, url)
|
||||
const response = await auth.handler(
|
||||
rewriteRequestPath(request, '/api/auth/open-api/generate-schema'),
|
||||
)
|
||||
return addCorsHeaders(response, request, env)
|
||||
}
|
||||
|
||||
// Better Auth API — handles /api/auth/* (sign-up, sign-in, session, etc.)
|
||||
if (url.pathname.startsWith('/api/auth')) {
|
||||
const origin = request.headers.get('Origin') ?? ''
|
||||
const allowedOrigins = getConfiguredAllowedOrigins(env)
|
||||
|
||||
// Verify origin is allowed before processing auth request
|
||||
if (origin && allowedOrigins.length > 0 && !allowedOrigins.includes(origin)) {
|
||||
return new Response('Origin not allowed', { status: 403 })
|
||||
}
|
||||
|
||||
const auth = createAuthInstance(env, url)
|
||||
const response = await auth.handler(request)
|
||||
return addCorsHeaders(response, request, env)
|
||||
}
|
||||
|
||||
// All relay routes go to a single global Durable Object instance.
|
||||
const id = env.CHAT_RELAY.idFromName('global-relay')
|
||||
const stub = env.CHAT_RELAY.get(id)
|
||||
|
||||
const response = await stub.fetch(request)
|
||||
|
||||
// Add CORS headers to non-WebSocket responses
|
||||
if (response.webSocket) {
|
||||
return response
|
||||
}
|
||||
|
||||
return addCorsHeaders(response, request, env)
|
||||
},
|
||||
} satisfies ExportedHandler<Env>
|
||||
414
src/protocol.ts
Normal file
414
src/protocol.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* Re-export shared protocol types for the relay worker.
|
||||
* Duplicated here to avoid cross-package dependency in Cloudflare Worker bundling.
|
||||
*/
|
||||
|
||||
export type WebSocketRole = 'browser' | 'openclaw'
|
||||
|
||||
export type AuthMessage = {
|
||||
type: 'auth'
|
||||
role: WebSocketRole
|
||||
token: string
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export type AuthResultMessage = {
|
||||
type: 'auth_result'
|
||||
ok: boolean
|
||||
error?: string
|
||||
sessionId?: string
|
||||
userId?: string
|
||||
userName?: string
|
||||
lobsterName?: string
|
||||
}
|
||||
|
||||
export type ChatMessage = {
|
||||
type: 'message'
|
||||
id: string
|
||||
sessionId: string
|
||||
from: 'user' | 'ai'
|
||||
text: string
|
||||
timestamp: number
|
||||
userId?: string
|
||||
userName?: string
|
||||
targetLobsterId?: string
|
||||
}
|
||||
|
||||
export type AckMessage = {
|
||||
type: 'ack'
|
||||
messageId: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export type TypingMessage = {
|
||||
type: 'typing'
|
||||
sessionId: string
|
||||
from: 'user' | 'ai'
|
||||
isTyping: boolean
|
||||
}
|
||||
|
||||
export type HistoryRequestMessage = {
|
||||
type: 'history_request'
|
||||
sessionId: string
|
||||
before?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export type HistoryResponseMessage = {
|
||||
type: 'history_response'
|
||||
sessionId: string
|
||||
messages: ChatMessage[]
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export type PingMessage = {
|
||||
type: 'ping'
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type PongMessage = {
|
||||
type: 'pong'
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type ErrorMessage = {
|
||||
type: 'error'
|
||||
code: string
|
||||
message: string
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export type SessionQueueMode =
|
||||
| 'collect'
|
||||
| 'followup'
|
||||
| 'steer'
|
||||
| 'interrupt'
|
||||
| 'steer-backlog'
|
||||
|
||||
export type QueueStatusState = 'processing' | 'enqueued' | 'idle'
|
||||
|
||||
export type QueueStatusSessionEvent = {
|
||||
event: 'queue_status'
|
||||
state: QueueStatusState
|
||||
mode?: SessionQueueMode
|
||||
depth?: number
|
||||
messageId?: string
|
||||
}
|
||||
|
||||
export type SessionEventPayload = QueueStatusSessionEvent
|
||||
|
||||
export type SessionEventMessage = {
|
||||
type: 'session_event'
|
||||
sessionId: string
|
||||
timestamp: number
|
||||
payload: SessionEventPayload
|
||||
}
|
||||
|
||||
// Lobster 上/下线推送给 browser
|
||||
export type LobsterStatusMessage = {
|
||||
type: 'lobster_status'
|
||||
action: 'online' | 'offline' | 'list'
|
||||
lobsters: Array<{ lobsterId: string; lobsterName: string }>
|
||||
}
|
||||
|
||||
// Browser 上/下线推送给 openclaw
|
||||
export type UserStatusMessage = {
|
||||
type: 'user_status'
|
||||
action: 'online' | 'offline'
|
||||
sessionId: string
|
||||
userId: string
|
||||
userName?: string
|
||||
}
|
||||
|
||||
// 全局 WebSocket 动态订阅/退订会话
|
||||
export type SessionJoinMessage = {
|
||||
type: 'session_join'
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export type SessionLeaveMessage = {
|
||||
type: 'session_leave'
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export type HistoryClearMessage = {
|
||||
type: 'history_clear'
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export type HistoryClearResultMessage = {
|
||||
type: 'history_clear_result'
|
||||
sessionId: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type RelayMessage =
|
||||
| AuthMessage
|
||||
| AuthResultMessage
|
||||
| ChatMessage
|
||||
| AckMessage
|
||||
| TypingMessage
|
||||
| HistoryRequestMessage
|
||||
| HistoryResponseMessage
|
||||
| PingMessage
|
||||
| PongMessage
|
||||
| ErrorMessage
|
||||
| SessionEventMessage
|
||||
| LobsterStatusMessage
|
||||
| UserStatusMessage
|
||||
| SessionJoinMessage
|
||||
| SessionLeaveMessage
|
||||
| HistoryClearMessage
|
||||
| HistoryClearResultMessage
|
||||
|
||||
export function parseRelayMessage(raw: string): RelayMessage | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (typeof parsed !== 'object' || parsed === null || !('type' in parsed)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const msg = parsed as { type: string }
|
||||
|
||||
switch (msg.type) {
|
||||
case 'auth':
|
||||
return isValidAuthMessage(parsed) ? (parsed as AuthMessage) : null
|
||||
case 'auth_result':
|
||||
return isValidAuthResultMessage(parsed) ? (parsed as AuthResultMessage) : null
|
||||
case 'message':
|
||||
return isValidChatMessage(parsed) ? (parsed as ChatMessage) : null
|
||||
case 'ack':
|
||||
return isValidAckMessage(parsed) ? (parsed as AckMessage) : null
|
||||
case 'typing':
|
||||
return isValidTypingMessage(parsed) ? (parsed as TypingMessage) : null
|
||||
case 'history_request':
|
||||
return isValidHistoryRequestMessage(parsed) ? (parsed as HistoryRequestMessage) : null
|
||||
case 'history_response':
|
||||
return isValidHistoryResponseMessage(parsed) ? (parsed as HistoryResponseMessage) : null
|
||||
case 'ping':
|
||||
return isValidPingMessage(parsed) ? (parsed as PingMessage) : null
|
||||
case 'pong':
|
||||
return isValidPongMessage(parsed) ? (parsed as PongMessage) : null
|
||||
case 'error':
|
||||
return isValidErrorMessage(parsed) ? (parsed as ErrorMessage) : null
|
||||
case 'session_event':
|
||||
return isValidSessionEventMessage(parsed) ? (parsed as SessionEventMessage) : null
|
||||
case 'lobster_status':
|
||||
return isValidLobsterStatusMessage(parsed) ? (parsed as LobsterStatusMessage) : null
|
||||
case 'user_status':
|
||||
return isValidUserStatusMessage(parsed) ? (parsed as UserStatusMessage) : null
|
||||
case 'session_join':
|
||||
return isValidSessionJoinMessage(parsed) ? (parsed as SessionJoinMessage) : null
|
||||
case 'session_leave':
|
||||
return isValidSessionLeaveMessage(parsed) ? (parsed as SessionLeaveMessage) : null
|
||||
case 'history_clear':
|
||||
return isValidHistoryClearMessage(parsed) ? (parsed as HistoryClearMessage) : null
|
||||
case 'history_clear_result':
|
||||
return isValidHistoryClearResultMessage(parsed) ? (parsed as HistoryClearResultMessage) : null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Type guards for each message type
|
||||
function isValidAuthMessage(msg: unknown): msg is AuthMessage {
|
||||
return (
|
||||
typeof msg === 'object' && msg !== null &&
|
||||
(msg as AuthMessage).type === 'auth' &&
|
||||
typeof (msg as AuthMessage).role === 'string' &&
|
||||
['browser', 'openclaw'].includes((msg as AuthMessage).role) &&
|
||||
typeof (msg as AuthMessage).token === 'string' &&
|
||||
((msg as AuthMessage).sessionId === undefined || typeof (msg as AuthMessage).sessionId === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
function isValidAuthResultMessage(msg: unknown): msg is AuthResultMessage {
|
||||
return (
|
||||
typeof msg === 'object' && msg !== null &&
|
||||
(msg as AuthResultMessage).type === 'auth_result' &&
|
||||
typeof (msg as AuthResultMessage).ok === 'boolean' &&
|
||||
((msg as AuthResultMessage).error === undefined || typeof (msg as AuthResultMessage).error === 'string') &&
|
||||
((msg as AuthResultMessage).sessionId === undefined || typeof (msg as AuthResultMessage).sessionId === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
function isValidChatMessage(msg: unknown): msg is ChatMessage {
|
||||
return (
|
||||
typeof msg === 'object' && msg !== null &&
|
||||
(msg as ChatMessage).type === 'message' &&
|
||||
typeof (msg as ChatMessage).id === 'string' &&
|
||||
typeof (msg as ChatMessage).sessionId === 'string' &&
|
||||
['user', 'ai'].includes((msg as ChatMessage).from) &&
|
||||
typeof (msg as ChatMessage).text === 'string' &&
|
||||
typeof (msg as ChatMessage).timestamp === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function isValidAckMessage(msg: unknown): msg is AckMessage {
|
||||
return (
|
||||
typeof msg === 'object' && msg !== null &&
|
||||
(msg as AckMessage).type === 'ack' &&
|
||||
typeof (msg as AckMessage).messageId === 'string' &&
|
||||
typeof (msg as AckMessage).sessionId === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isValidTypingMessage(msg: unknown): msg is TypingMessage {
|
||||
return (
|
||||
typeof msg === 'object' && msg !== null &&
|
||||
(msg as TypingMessage).type === 'typing' &&
|
||||
typeof (msg as TypingMessage).sessionId === 'string' &&
|
||||
['user', 'ai'].includes((msg as TypingMessage).from) &&
|
||||
typeof (msg as TypingMessage).isTyping === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
function isValidHistoryRequestMessage(msg: unknown): msg is HistoryRequestMessage {
|
||||
const m = msg as HistoryRequestMessage
|
||||
return (
|
||||
typeof msg === 'object' && msg !== null &&
|
||||
m.type === 'history_request' &&
|
||||
typeof m.sessionId === 'string' &&
|
||||
(m.before === undefined || typeof m.before === 'number') &&
|
||||
(m.limit === undefined || typeof m.limit === 'number')
|
||||
)
|
||||
}
|
||||
|
||||
function isValidHistoryResponseMessage(msg: unknown): msg is HistoryResponseMessage {
|
||||
const m = msg as HistoryResponseMessage
|
||||
return (
|
||||
typeof msg === 'object' && msg !== null &&
|
||||
m.type === 'history_response' &&
|
||||
typeof m.sessionId === 'string' &&
|
||||
Array.isArray(m.messages) &&
|
||||
typeof m.hasMore === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
function isValidPingMessage(msg: unknown): msg is PingMessage {
|
||||
return (
|
||||
typeof msg === 'object' && msg !== null &&
|
||||
(msg as PingMessage).type === 'ping' &&
|
||||
typeof (msg as PingMessage).timestamp === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function isValidPongMessage(msg: unknown): msg is PongMessage {
|
||||
return (
|
||||
typeof msg === 'object' && msg !== null &&
|
||||
(msg as PongMessage).type === 'pong' &&
|
||||
typeof (msg as PongMessage).timestamp === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function isValidErrorMessage(msg: unknown): msg is ErrorMessage {
|
||||
const m = msg as ErrorMessage
|
||||
return (
|
||||
typeof msg === 'object' && msg !== null &&
|
||||
m.type === 'error' &&
|
||||
typeof m.code === 'string' &&
|
||||
typeof m.message === 'string' &&
|
||||
(m.sessionId === undefined || typeof m.sessionId === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
function isValidSessionEventMessage(msg: unknown): msg is SessionEventMessage {
|
||||
const m = msg as SessionEventMessage
|
||||
return (
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
m.type === 'session_event' &&
|
||||
typeof m.sessionId === 'string' &&
|
||||
typeof m.timestamp === 'number' &&
|
||||
isValidSessionEventPayload(m.payload)
|
||||
)
|
||||
}
|
||||
|
||||
function isValidSessionEventPayload(payload: unknown): payload is SessionEventPayload {
|
||||
if (typeof payload !== 'object' || payload === null) return false
|
||||
const typed = payload as SessionEventPayload
|
||||
if (typed.event !== 'queue_status') return false
|
||||
if (!['processing', 'enqueued', 'idle'].includes(typed.state)) return false
|
||||
if (
|
||||
typed.mode !== undefined &&
|
||||
!['collect', 'followup', 'steer', 'interrupt', 'steer-backlog'].includes(typed.mode)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
(typed.depth === undefined || typeof typed.depth === 'number') &&
|
||||
(typed.messageId === undefined || typeof typed.messageId === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
function isValidLobsterStatusMessage(msg: unknown): msg is LobsterStatusMessage {
|
||||
const m = msg as LobsterStatusMessage
|
||||
return (
|
||||
typeof msg === 'object' && msg !== null &&
|
||||
m.type === 'lobster_status' &&
|
||||
typeof m.action === 'string' &&
|
||||
['online', 'offline', 'list'].includes(m.action) &&
|
||||
Array.isArray(m.lobsters)
|
||||
)
|
||||
}
|
||||
|
||||
function isValidUserStatusMessage(msg: unknown): msg is UserStatusMessage {
|
||||
const m = msg as UserStatusMessage
|
||||
return (
|
||||
typeof msg === 'object' && msg !== null &&
|
||||
m.type === 'user_status' &&
|
||||
typeof m.action === 'string' &&
|
||||
['online', 'offline'].includes(m.action) &&
|
||||
typeof m.sessionId === 'string' &&
|
||||
typeof m.userId === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isValidSessionJoinMessage(msg: unknown): msg is SessionJoinMessage {
|
||||
const m = msg as SessionJoinMessage
|
||||
return (
|
||||
typeof msg === 'object' && msg !== null &&
|
||||
m.type === 'session_join' &&
|
||||
typeof m.sessionId === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isValidSessionLeaveMessage(msg: unknown): msg is SessionLeaveMessage {
|
||||
const m = msg as SessionLeaveMessage
|
||||
return (
|
||||
typeof msg === 'object' && msg !== null &&
|
||||
m.type === 'session_leave' &&
|
||||
typeof m.sessionId === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isValidHistoryClearMessage(msg: unknown): msg is HistoryClearMessage {
|
||||
const m = msg as HistoryClearMessage
|
||||
return (
|
||||
typeof msg === 'object' && msg !== null &&
|
||||
m.type === 'history_clear' &&
|
||||
typeof m.sessionId === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isValidHistoryClearResultMessage(msg: unknown): msg is HistoryClearResultMessage {
|
||||
const m = msg as HistoryClearResultMessage
|
||||
return (
|
||||
typeof msg === 'object' && msg !== null &&
|
||||
m.type === 'history_clear_result' &&
|
||||
typeof m.sessionId === 'string' &&
|
||||
typeof m.ok === 'boolean' &&
|
||||
(m.error === undefined || typeof m.error === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
export function generateMessageId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
export const MAX_HISTORY_PER_SESSION = 200
|
||||
export const DEFAULT_HISTORY_LIMIT = 50
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022"],
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
10875
worker-configuration.d.ts
vendored
Normal file
10875
worker-configuration.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
85
wrangler.jsonc
Normal file
85
wrangler.jsonc
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"name": "web-relay",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2025-03-10",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"migrations": [
|
||||
{
|
||||
"tag": "v1",
|
||||
"new_classes": [
|
||||
"ChatRelay"
|
||||
],
|
||||
},
|
||||
],
|
||||
"observability": {
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"invocation_logs": true
|
||||
}
|
||||
},
|
||||
// Shared bindings & dev defaults — available via `wrangler dev`
|
||||
"vars": {
|
||||
"BETTER_AUTH_URL": "http://192.168.0.117:8787/api/auth",
|
||||
"RELAY_URL": "http://192.168.0.117:8787",
|
||||
"BETTER_AUTH_SECRET": "It3546porQSYeKhQyplQRRjmohE5AmR6rF4WoKq85t0=",
|
||||
"OPENCLAW_AUTH_TOKEN": "It3546porQSYeKhQyplQRRjmohE5AmR6rF4WoKq85t0=",
|
||||
"AWS_ACCESS_KEY_ID": "AKIAYRH5NGRSWHN2L4M6",
|
||||
"AWS_SECRET_ACCESS_KEY": "kfAqoOmIiyiywi25xaAkJUQbZ/EKDnzvI6NRCW1l",
|
||||
"AWS_BUCKET_NAME": "modal-media-cache",
|
||||
"AWS_LOCATION": "ap-northeast-2",
|
||||
"ALLOWED_ORIGINS": "http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003,http://192.168.0.117:3000"
|
||||
},
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "CHAT_RELAY",
|
||||
"class_name": "ChatRelay",
|
||||
},
|
||||
],
|
||||
},
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "AUTH_DB",
|
||||
"database_name": "clawshow-db",
|
||||
"database_id": "b2d654ab-20b8-4502-b37f-cbcd1ce80e00",
|
||||
},
|
||||
],
|
||||
"env": {
|
||||
"prod": {
|
||||
"vars": {
|
||||
"BETTER_AUTH_URL": "https://clawshow-api.bowong.cc/api/auth",
|
||||
"BETTER_AUTH_SECRET": "It3546porQSYeKhQyplQRRjmohE5AmR6rF4WoKq85t0=",
|
||||
"OPENCLAW_AUTH_TOKEN": "It3546porQSYeKhQyplQRRjmohE5AmR6rF4WoKq85t0=",
|
||||
"RELAY_URL": "https://clawshow-api.bowong.cc",
|
||||
"AWS_ACCESS_KEY_ID": "AKIAYRH5NGRSWHN2L4M6",
|
||||
"AWS_SECRET_ACCESS_KEY": "kfAqoOmIiyiywi25xaAkJUQbZ/EKDnzvI6NRCW1l",
|
||||
"AWS_BUCKET_NAME": "modal-media-cache",
|
||||
"AWS_LOCATION": "ap-northeast-2",
|
||||
"ALLOWED_ORIGINS": "http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003,https://clawshow.bowong.cc"
|
||||
},
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "CHAT_RELAY",
|
||||
"class_name": "ChatRelay"
|
||||
}
|
||||
]
|
||||
},
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "AUTH_DB",
|
||||
"database_name": "clawshow-db",
|
||||
"database_id": "b2d654ab-20b8-4502-b37f-cbcd1ce80e00"
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "clawshow-api.bowong.cc",
|
||||
"custom_domain": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user