feat(server): standalone Hono dev runtime for apps/server (dev:hono-lite)

Ports PR #14800's standalone Hono dev runtime onto the new apps/server
package. bun run dev:hono-lite boots Hono on :3011 + Vite on :9876 with
NO Next.js process, serving the full near-parity API surface:

  /trpc/*   - lambda / mobile / tools / async TRPC routers
  /webapi/* - chat, models, createImage, speech, trace
  /market/* - agent / model / plugin market
  /oidc/*   - OIDC provider
  /f/*      - fileProxy + userAvatar
  /api/*    - auth, webhooks, dev, v1, memory (catch-all, registered last)

Better Auth's /api/auth/* and dev-local-login flow run end-to-end;
authenticated tRPC queries return real user data. bun run dev (Next)
stays unbroken.

Squashed from 11 commits ported from the original branch:
  - L0 core: runtime-neutral scheduleAfterResponse (drop next/server
    from apps/server), tRPC runtime handlers + Hono /trpc sub-app,
    standalone Hono root app + Node entry, dev:hono-lite topology
    (devHonoLite.mts + devTopology.ts + viteNodeServer.config.ts),
    Better Auth dev-local-login bootstrap, @lobehub/editor dedupe
  - L1 webapi: chat, models, createImage, speech, trace
  - L2 misc: market, oidc, fileProxy, userAvatar
  - L3 agent + api catch-all: agentStream, agentEvalRunWorkflow,
    agent-hono / workflows-hono mounts, api-hono catch-all

Dev ergonomics:
  - vite-node@3.2.4 as a workspace devDep (was bunx-fetched per-run)
  - npx vite-node instead of bunx vite-node@3.2.4 in dev:hono:server
  - unified process.title across dev nodes: lobe-dev, lobe-dev-hono-lite,
    lobe-dev-hono-${port}, lobe-dev-vite-${platform}, lobe-dev-login
  - fix lobe-dev-proxy-print plugin swallowing Vite's Local/Network
    URLs in printUrls; now: Local + Network + Hono API + Debug Proxy

Docs: apps/server/README.md (routes, ports, dev-login gate, known
gaps, troubleshooting); docs/development/start.mdx EN/zh-CN section.

Tier T1 (dev-runnable only): gray-release machinery, production-
deployable apps/server (T2/T3), and a handful of post-#14800 routes
(oauth/connector/callback, api/dev/test-push, webapi/revalidate,
agent-eval-run extras) are intentionally out of scope.
This commit is contained in:
Innei
2026-06-09 20:25:40 +08:00
parent 4a11ed9887
commit 30835b27d2
61 changed files with 4623 additions and 68 deletions
+109
View File
@@ -0,0 +1,109 @@
# @lobechat/server
The LobeHub backend package. Contains the TRPC routers, runtime handlers, services, feature flags, global config, and the standalone Hono dev runtime.
> **Status: T1 dev-runtime POC.** The standalone Hono entry (`dev:hono-lite`) is a development-only runtime intended for fast inner-loop work without Next.js. It is NOT yet a production-deployable server — gray-release machinery and the production tier (T2/T3) are tracked separately.
## Package Layout
```
apps/server/src/
├── api-hono/ # /api/* catch-all (auth, webhooks, dev, v1, memory)
├── api-runtime/ # Per-route handlers (chat, models, oidc, market, ...)
├── featureFlags/ # Feature flag resolution
├── globalConfig/ # Server-side runtime config
├── hono/ # Standalone Hono root app + Node entry
│ ├── index.ts # Hono root app — mounts /webapi, /market, /oidc, /f, /trpc, /api
│ └── standalone.ts # Node entry — used by dev:hono:server
├── modules/ # Domain modules (no DB access)
├── routers/ # TRPC routers (async, lambda, mobile, tools)
├── runtimeConfig/ # Runtime context (DB, auth, ...)
├── services/ # Business services (can access DB)
├── utils/ # Shared utilities
└── workflows/ # Upstash workflow handlers
```
The package's exports resolve via the `@/server/*` alias (dual-path tsconfig: `apps/server/src/*` first, `src/server/*` fallback for the pieces that still live there — `agent-hono`, `workflows-hono`).
## Dev Modes
| Mode | Command | Topology |
| ------------------- | ----------------------- | ---------------------------------------- |
| **Classic** | `bun run dev` | Next (`:3010`) + Vite (`:9876`) |
| **Hono-Lite** (POC) | `bun run dev:hono-lite` | Hono (`:3011`) + Vite (`:9876`), no Next |
Both modes coexist on this branch — pick whichever fits the task.
### Hono-Lite Startup
Prerequisites are the same as classic dev — see [`docs/development/basic/setup-development`](../../docs/development/basic/setup-development.mdx) (Docker services, `.env`, DB migrations).
```bash
# 1. Make sure Docker services are up (Postgres / Redis / RustFS / SearXNG).
bun run dev:docker
# 2. Boot Hono + Vite. dev:hono-lite spawns both, waits for :3011, then
# starts Vite. Either child exiting tears the whole thing down.
bun run dev:hono-lite
# 3. Open a local dev session. Better Auth's dev-local-login endpoint is
# enabled automatically under the hono-lite topology and issues a real
# session cookie. dev:login opens the right URL in your browser.
bun run dev:login
# 4. Use the SPA.
open http://localhost:9876
```
### What Hono Serves
The standalone Hono root app (`apps/server/src/hono/index.ts`) serves the full near-parity API surface:
- `/trpc/*` — TRPC routers (lambda, mobile, tools, async)
- `/webapi/*` — chat, models, createImage, speech, trace
- `/market/*` — agent / model / plugin market
- `/oidc/*` — OIDC provider
- `/f/*` — fileProxy + userAvatar
- `/api/*` — auth, webhooks, dev, v1, memory (catch-all, registered last)
The `/api/auth/*` mount uses Better Auth's handler. Webhook signature verification is preserved end-to-end.
### Ports
| Env Var | Default |
| -------------------------- | ------- |
| `HONO_PORT` | `3011` |
| `VITE_PORT` | `9876` |
| `PORT` (classic Next mode) | `3010` |
### Dev-Login Flag
`devTopology.ts` auto-sets `LOBE_DEV_AUTH_BOOTSTRAP=1` whenever the topology is `hono-lite`. Better Auth's `/api/auth/dev/local-login` endpoint is only registered when both `LOBE_DEV_AUTH_BOOTSTRAP=1` and `NODE_ENV=development` hold — so it never leaks into production builds.
## Known Gaps (POC Scope)
The following are intentionally out of scope for the T1 dev runtime:
1. **Gray-release / production tier** — no `runtime.ts`/`next.ts` switcher, no production-deployable entry. T2/T3 will land separately.
2. **`vite.config.ts` (SPA) dep-scan warning** — non-fatal `@lobehub/editor/litexml-commands` warning persists; durable cure is deduping the lockfile so `packages/editor-runtime/node_modules/@lobehub/editor@4.15.2` no longer shadows the root `4.16.1`.
3. **Hono root is \~37 flat routes** — should be split into `webapi`/`market`/`oidc` sub-apps via `app.route(...)` before leaving POC; market segment-splitter is duplicated 6×.
4. **Unmounted routes** that postdate the original #14800:
- `oauth/connector/callback` (LOBE-998 custom-MCP-connector OAuth — most user-facing of the gaps)
- `api/dev/test-push` (PR #15233, dev-only)
- `webapi/revalidate` (PR #15146 — uses Next-only `next/cache.revalidateTag`, will never port)
- agent-eval-run extras: execute-test-case, finalize-run, paginate, resume-\*, run-\* (eval/benchmark dev endpoints)
## Troubleshooting
| Symptom | Likely Cause / Fix |
| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `vite-node` resolves the wrong `@lobehub/editor` and tRPC blows up | `resolve.dedupe:['@lobehub/editor']` in `scripts/viteNodeServer.config.ts` works around the pnpm duplicate. If you tweak that config, keep the dedupe. |
| `webapi/models` returns 500 in dev | Local DB schema drift — usually missing `ai_providers._id`. Run `bun run db:migrate`. |
| `Hono server was not ready within 180s` | Vite-node failed to bundle the entry; check the `dev:hono:server` stderr — most often a missing env or a broken import path under `apps/server/src/hono/`. |
| `vite-node` version mismatch | `vite-node` is a workspace devDep pinned to `3.2.4` (matching the workspace `vitest` / Vite 8 stack). Don't bump it independently. |
## See Also
- PR #15582 — the POC PR (stacked on `refactor/server-deps/business`).
- PR #14800 — the original gray-release Hono runtime (this POC ports its idea onto `apps/server`).
- `scripts/devHonoLite.mts`, `scripts/devTopology.ts`, `scripts/devLocalLogin.mts` — orchestration internals.
+68
View File
@@ -0,0 +1,68 @@
import { Hono } from 'hono';
import { versionAPIHandler } from '@/server/api-runtime/version';
const app = new Hono().basePath('/api');
const fetchOpenAPI = async (request: Request) =>
(await import('@/server/api-runtime/openapi')).openAPIHandler(request);
const fetchCheckUser = async (request: Request) =>
(await import('@/server/api-runtime/auth')).checkUserAPIHandler(request);
const fetchResolveUsername = async (request: Request) =>
(await import('@/server/api-runtime/auth')).resolveUsernameAPIHandler(request);
const fetchBetterAuth = async (request: Request) =>
(await import('@/server/api-runtime/betterAuth')).betterAuthAPIHandler(request);
const fetchCasdoorWebhook = async (request: Request) =>
(await import('@/server/api-runtime/webhooks')).casdoorWebhookAPIHandler(request);
const fetchLogtoWebhook = async (request: Request) =>
(await import('@/server/api-runtime/webhooks')).logtoWebhookAPIHandler(request);
const fetchMemoryExtractChatTopicCancelWebhook = async (request: Request) =>
(
await import('@/server/api-runtime/memoryExtraction')
).memoryExtractChatTopicCancelWebhookAPIHandler(request);
const fetchMemoryExtractionWebhook = async (request: Request) =>
(await import('@/server/api-runtime/memoryExtraction')).memoryExtractionWebhookAPIHandler(
request,
);
const fetchMemoryExtractionBenchmarkLoCoMoWebhook = async (request: Request) =>
(
await import('@/server/api-runtime/memoryExtractionBenchmark')
).memoryExtractionBenchmarkLoCoMoWebhookAPIHandler(request);
const fetchMemoryUserPersonaUpdateWritingWebhook = async (request: Request) =>
(
await import('@/server/api-runtime/memoryExtraction')
).memoryUserPersonaUpdateWritingWebhookAPIHandler(request);
const fetchVideoWebhook = async (request: Request, provider: string) =>
(await import('@/server/api-runtime/videoWebhook')).videoWebhookAPIHandler(request, { provider });
const fetchAgentTracing = async (request: Request) =>
(await import('@/server/api-runtime/dev')).agentTracingAPIHandler(request);
const fetchMemoryUserMemoryBenchmarkLoCoMoDev = async (request: Request) =>
(
await import('@/server/api-runtime/memoryBenchmarkDev')
).memoryUserMemoryBenchmarkLoCoMoDevAPIHandler(request);
app.post('/auth/check-user', (c) => fetchCheckUser(c.req.raw));
app.post('/auth/resolve-username', (c) => fetchResolveUsername(c.req.raw));
app.on(['GET', 'POST'], '/auth/*', (c) => fetchBetterAuth(c.req.raw));
app.get('/dev/agent-tracing', (c) => fetchAgentTracing(c.req.raw));
app.post('/dev/memory-user-memory/benchmark-locomo', (c) =>
fetchMemoryUserMemoryBenchmarkLoCoMoDev(c.req.raw),
);
app.post('/webhooks/casdoor', (c) => fetchCasdoorWebhook(c.req.raw));
app.post('/webhooks/logto', (c) => fetchLogtoWebhook(c.req.raw));
app.post('/webhooks/memory-extraction', (c) => fetchMemoryExtractionWebhook(c.req.raw));
app.post('/webhooks/memory-extraction/benchmark-locomo', (c) =>
fetchMemoryExtractionBenchmarkLoCoMoWebhook(c.req.raw),
);
app.post('/webhooks/memory-user-memory/pipelines/extract/chat-topic/cancel', (c) =>
fetchMemoryExtractChatTopicCancelWebhook(c.req.raw),
);
app.post('/webhooks/memory-user-memory/persona/update-writing', (c) =>
fetchMemoryUserPersonaUpdateWritingWebhook(c.req.raw),
);
app.post('/webhooks/video/:provider', (c) => fetchVideoWebhook(c.req.raw, c.req.param('provider')));
app.all('/v1', (c) => fetchOpenAPI(c.req.raw));
app.all('/v1/*', (c) => fetchOpenAPI(c.req.raw));
app.get('/version', (c) => versionAPIHandler(c.req.raw));
export default app;
@@ -0,0 +1,200 @@
import debug from 'debug';
import type {
OnThreadCompletePayload,
OnTrajectoryCompletePayload,
} from '@/server/workflows/agentEvalRun';
const threadLog = debug('lobe-server:workflows:on-thread-complete');
const trajectoryLog = debug('lobe-server:workflows:on-trajectory-complete');
export const agentEvalRunOnThreadCompleteAPIHandler = async (request: Request) => {
try {
const body = (await request.json()) as OnThreadCompletePayload;
const {
runId,
testCaseId,
threadId,
topicId,
userId,
operationId: _operationId,
reason,
status,
cost,
duration,
errorMessage,
llmCalls,
steps,
toolCalls,
totalTokens,
} = body;
if (!runId || !testCaseId || !threadId || !topicId || !userId) {
return Response.json({ error: 'Missing required fields' }, { status: 400 });
}
threadLog(
'Received: runId=%s testCaseId=%s threadId=%s status=%s cost=%s duration=%s',
runId,
testCaseId,
threadId,
status,
cost,
duration,
);
const [{ AgentEvalRunModel }, { getServerDB }, { AgentEvalRunService }] = await Promise.all([
import('@/database/models/agentEval'),
import('@/database/server'),
import('@/server/services/agentEvalRun'),
]);
const db = await getServerDB();
const runModel = new AgentEvalRunModel(db, userId);
const run = await runModel.findById(runId);
if (run?.status === 'aborted') {
threadLog(
'Run aborted, skipping: runId=%s testCaseId=%s threadId=%s',
runId,
testCaseId,
threadId,
);
return Response.json({ cancelled: true });
}
const service = new AgentEvalRunService(db, userId);
const { allThreadsDone, allRunDone } = await service.recordThreadCompletion({
runId,
status,
telemetry: {
completionReason: reason,
cost,
duration,
errorMessage,
llmCalls,
steps,
toolCalls,
totalTokens,
},
testCaseId,
threadId,
topicId,
});
threadLog(
'Thread completion: threadId=%s allThreadsDone=%s allRunDone=%s',
threadId,
allThreadsDone,
allRunDone,
);
if (allRunDone) {
console.info(
'[on-thread-complete] All test cases done for run %s, triggering finalize',
runId,
);
const { AgentEvalRunWorkflow } = await import('@/server/workflows/agentEvalRun');
await AgentEvalRunWorkflow.triggerFinalizeRun({ runId, userId });
}
return Response.json({ allRunDone, allThreadsDone, success: true });
} catch (error) {
console.error('[on-thread-complete] Error:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Internal error' },
{ status: 500 },
);
}
};
export const agentEvalRunOnTrajectoryCompleteAPIHandler = async (request: Request) => {
try {
const body = (await request.json()) as OnTrajectoryCompletePayload;
const {
runId,
testCaseId,
userId,
operationId,
reason,
status,
cost,
duration,
errorDetail,
errorMessage,
llmCalls,
steps,
toolCalls,
totalTokens,
} = body;
if (!runId || !testCaseId || !userId) {
return Response.json({ error: 'Missing required fields' }, { status: 400 });
}
trajectoryLog(
'Received: runId=%s testCaseId=%s operationId=%s reason=%s status=%s cost=%s duration=%s steps=%s totalTokens=%s',
runId,
testCaseId,
operationId,
reason,
status,
cost,
duration,
steps,
totalTokens,
);
const [{ AgentEvalRunModel }, { getServerDB }, { AgentEvalRunService }] = await Promise.all([
import('@/database/models/agentEval'),
import('@/database/server'),
import('@/server/services/agentEvalRun'),
]);
const db = await getServerDB();
const runModel = new AgentEvalRunModel(db, userId);
const run = await runModel.findById(runId);
if (run?.status === 'aborted') {
trajectoryLog('Run aborted, skipping: runId=%s testCaseId=%s', runId, testCaseId);
return Response.json({ cancelled: true });
}
const service = new AgentEvalRunService(db, userId);
const { allDone, completedCount } = await service.recordTrajectoryCompletion({
runId,
status,
telemetry: {
completionReason: reason,
cost,
duration,
errorDetail,
errorMessage,
llmCalls,
steps,
toolCalls,
totalTokens,
},
testCaseId,
});
trajectoryLog('Completion check: %d completed, allDone=%s', completedCount, allDone);
if (allDone) {
console.info(
'[on-trajectory-complete] All test cases done for run %s, triggering finalize',
runId,
);
const { AgentEvalRunWorkflow } = await import('@/server/workflows/agentEvalRun');
await AgentEvalRunWorkflow.triggerFinalizeRun({ runId, userId });
}
return Response.json({ success: true });
} catch (error) {
console.error('[on-trajectory-complete] Error:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Internal error' },
{ status: 500 },
);
}
};
+179
View File
@@ -0,0 +1,179 @@
import { createSSEHeaders, createSSEWriter } from '@lobechat/utils/server';
import debug from 'debug';
import { createStreamEventManager } from '@/server/modules/AgentRuntime';
const log = debug('api-route:agent:stream');
const timing = debug('lobe-server:agent-runtime:timing');
export const agentStreamAPIHandler = async (request: Request) => {
const streamManager = createStreamEventManager();
const { searchParams } = new URL(request.url);
const operationId = searchParams.get('operationId');
const lastEventId = searchParams.get('lastEventId') || '0';
const includeHistory = searchParams.get('includeHistory') === 'true';
if (!operationId) {
return Response.json(
{
error: 'operationId parameter is required',
},
{ status: 400 },
);
}
log(`Starting SSE connection for operation ${operationId} from eventId ${lastEventId}`);
let cleanup: (() => void) | undefined;
const stream = new ReadableStream({
cancel(reason) {
log(`SSE connection cancelled for operation ${operationId}:`, reason);
cleanup?.();
},
start(controller) {
const writer = createSSEWriter(controller);
writer.writeConnection(operationId, lastEventId);
log(`SSE connection established for operation ${operationId}`);
if (includeHistory) {
streamManager
.getStreamHistory(operationId, 50)
.then((history) => {
const sortedHistory = history.reverse();
sortedHistory.forEach((event) => {
if (!lastEventId || lastEventId === '0' || event.timestamp.toString() > lastEventId) {
try {
const sseEvent = {
...event,
operationId,
timestamp: event.timestamp || Date.now(),
};
writer.writeStreamEvent(sseEvent, operationId);
} catch (error) {
console.error('[Agent Stream] Error sending history event:', error);
}
}
});
if (sortedHistory.length > 0) {
log(`Sent ${sortedHistory.length} historical events for operation ${operationId}`);
}
})
.catch((error) => {
console.error('[Agent Stream] Failed to load history:', error);
try {
writer.writeError(error, operationId, 'history_loading');
} catch (controllerError) {
console.error('[Agent Stream] Failed to send error event:', controllerError);
}
});
}
const abortController = new AbortController();
let streamEnded = false;
const heartbeatInterval = setInterval(() => {
if (streamEnded) {
return;
}
try {
const heartbeat = {
operationId,
timestamp: Date.now(),
type: 'heartbeat',
};
controller.enqueue(`data: ${JSON.stringify(heartbeat)}\n\n`);
} catch (error) {
console.error('[Agent Stream] Heartbeat error:', error);
clearInterval(heartbeatInterval);
}
}, 30_000);
const closeStream = () => {
abortController.abort();
clearInterval(heartbeatInterval);
log(`SSE connection closed for operation ${operationId}`);
};
const subscribeToEvents = async () => {
try {
await streamManager.subscribeStreamEvents(
operationId,
lastEventId,
(events) => {
events.forEach((event) => {
if (streamEnded) {
return;
}
try {
const sseEvent = {
...event,
operationId,
timestamp: event.timestamp || Date.now(),
};
const now = Date.now();
const totalLatency = now - sseEvent.timestamp;
writer.writeStreamEvent(sseEvent, operationId);
timing(
'[%s:%d] SSE sent %s, original timestamp %d, sent at %d, total latency %dms',
operationId,
event.stepIndex,
event.type,
sseEvent.timestamp,
now,
totalLatency,
);
if (event.type === 'agent_runtime_end') {
log(
`Agent runtime ended for operation ${operationId}, terminating stream immediately`,
);
streamEnded = true;
closeStream();
controller.close();
log(
`SSE connection closed after agent runtime end for operation ${operationId}`,
);
}
} catch (error) {
console.error('[Agent Stream] Error sending event:', error);
}
});
},
abortController.signal,
);
} catch (error) {
if (!abortController.signal.aborted) {
console.error('[Agent Stream] Subscription error:', error);
try {
writer.writeError(error as Error, operationId, 'stream_subscription');
} catch (controllerError) {
console.error('[Agent Stream] Failed to send subscription error:', controllerError);
}
}
}
};
subscribeToEvents();
request.signal?.addEventListener('abort', closeStream);
cleanup = closeStream;
},
});
return new Response(stream, {
headers: createSSEHeaders(),
});
};
+96
View File
@@ -0,0 +1,96 @@
import { and, eq } from 'drizzle-orm';
import { account } from '@/database/schemas/betterAuth';
import { users } from '@/database/schemas/user';
import { serverDB } from '@/database/server';
export interface CheckUserResponseData {
exists: boolean;
hasPassword?: boolean;
}
export interface ResolveUsernameResponseData {
email?: string | null;
exists: boolean;
}
export const checkUserAPIHandler = async (request: Request): Promise<Response> => {
try {
const body = (await request.json()) as { email?: unknown };
const { email } = body;
if (!email || typeof email !== 'string') {
return Response.json({ error: 'Email is required', exists: false }, { status: 400 });
}
const [user] = await serverDB
.select({
emailVerified: users.emailVerified,
id: users.id,
})
.from(users)
.where(eq(users.email, email.toLowerCase().trim()))
.limit(1);
if (!user) {
return Response.json({ exists: false });
}
const accounts = await serverDB
.select({
password: account.password,
providerId: account.providerId,
})
.from(account)
.where(and(eq(account.userId, user.id)));
const hasPassword = accounts.some(
(item) =>
item.providerId === 'credential' &&
typeof item.password === 'string' &&
item.password.length > 0,
);
return Response.json({
exists: true,
hasPassword,
} satisfies CheckUserResponseData);
} catch (error) {
console.error('Error checking user existence:', error);
return Response.json({ error: 'Internal server error', exists: false }, { status: 500 });
}
};
export const resolveUsernameAPIHandler = async (request: Request): Promise<Response> => {
try {
const body = (await request.json()) as { username?: unknown };
const { username } = body;
if (!username || typeof username !== 'string') {
return Response.json({ error: 'Username is required', exists: false }, { status: 400 });
}
const normalizedUsername = username.trim();
if (!normalizedUsername) {
return Response.json({ error: 'Username is required', exists: false }, { status: 400 });
}
const [user] = await serverDB
.select({ email: users.email })
.from(users)
.where(eq(users.username, normalizedUsername))
.limit(1);
if (!user || !user.email) {
return Response.json({ exists: false } satisfies ResolveUsernameResponseData);
}
return Response.json({
email: user.email,
exists: true,
} satisfies ResolveUsernameResponseData);
} catch (error) {
console.error('Error resolving username to email:', error);
return Response.json({ error: 'Internal server error', exists: false }, { status: 500 });
}
};
+34
View File
@@ -0,0 +1,34 @@
import { toNextJsHandler } from 'better-auth/next-js';
import { auth } from '@/auth';
const jsonContentTypeRegex = /^application\/(?:[a-z0-9.+-]*\+)?json/i;
const betterAuthNextHandler = toNextJsHandler(auth);
const malformedJsonResponse = () =>
Response.json({ code: 'INVALID_JSON', message: 'Malformed JSON request body' }, { status: 400 });
const validateJsonBody = async (request: Request) => {
const contentType = request.headers.get('content-type') || '';
if (!request.body || !jsonContentTypeRegex.test(contentType)) return;
try {
await request.clone().json();
} catch (error) {
if (error instanceof SyntaxError) return malformedJsonResponse();
throw error;
}
};
export const betterAuthAPIHandler = async (request: Request) => {
if (request.method === 'GET') return betterAuthNextHandler.GET(request);
if (request.method === 'POST') {
const invalidJsonResponse = await validateJsonBody(request);
if (invalidJsonResponse) return invalidJsonResponse;
return betterAuthNextHandler.POST(request);
}
return new Response(null, { status: 405 });
};
+53
View File
@@ -0,0 +1,53 @@
import type { ChatCompletionErrorPayload } from '@lobechat/model-runtime';
import { AGENT_RUNTIME_ERROR_SET } from '@lobechat/model-runtime';
import { ChatErrorType } from '@lobechat/types';
import { checkAuth } from '@/app/(backend)/middleware/auth';
import { createTraceOptions, initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import type { ChatStreamPayload } from '@/types/openai/chat';
import { createErrorResponse } from '@/utils/errorResponse';
import { getTracePayload } from '@/utils/trace';
interface ProviderParams {
provider: string;
}
const createProviderParams = (provider: string) => Promise.resolve({ provider });
export const chatAPIHandler = (request: Request, { provider }: ProviderParams) =>
checkAuth(async (authedRequest, { params, userId, serverDB }) => {
const routeProvider = (await params).provider!;
try {
const modelRuntime = await initModelRuntimeFromDB(serverDB, userId, routeProvider);
const data = (await authedRequest.json()) as ChatStreamPayload;
const tracePayload = getTracePayload(authedRequest);
let traceOptions = {};
if (tracePayload?.enabled) {
traceOptions = createTraceOptions(data, { provider: routeProvider, trace: tracePayload });
}
return await modelRuntime.chat(data, {
user: userId,
...traceOptions,
signal: authedRequest.signal,
});
} catch (error_) {
const {
errorType = ChatErrorType.InternalServerError,
error: errorContent,
...res
} = error_ as ChatCompletionErrorPayload;
const error = errorContent || error_;
const logMethod = AGENT_RUNTIME_ERROR_SET.has(errorType as string) ? 'warn' : 'error';
// eslint-disable-next-line no-console
console[logMethod](`Route: [${routeProvider}] ${errorType}:`, error);
return createErrorResponse(errorType, { error, ...res, provider: routeProvider });
}
})(request, { params: createProviderParams(provider) });
@@ -0,0 +1,91 @@
import type { ClientSecretPayload } from '@lobechat/types';
import { checkAuth } from '@/app/(backend)/middleware/auth';
import { getServerDBConfig } from '@/config/db';
import { createCallerFactory } from '@/libs/trpc/lambda';
import { lambdaRouter } from '@/server/routers/lambda';
const serverDBEnv = getServerDBConfig();
interface ComfyUIHandlerOptions {
jwtPayload?: ClientSecretPayload;
}
const handleComfyUICreateImage = async (
request: Request,
{ jwtPayload }: ComfyUIHandlerOptions,
) => {
try {
const body = await request.json();
const { model, params, options } = body;
const createCaller = createCallerFactory(lambdaRouter);
const caller = createCaller({
jwtPayload,
userId: jwtPayload?.userId,
});
const result = await caller.comfyui.createImage({
model,
options,
params,
});
return Response.json(result);
} catch (error) {
console.error('[ComfyUI WebAPI] Error:', error);
const agentError =
error && typeof error === 'object' && 'cause' in error ? error.cause : undefined;
if (agentError && typeof agentError === 'object' && 'errorType' in agentError) {
const { errorType } = agentError;
let status;
switch (errorType) {
case 'InvalidProviderAPIKey':
case 401: {
status = 401;
break;
}
case 'PermissionDenied':
case 403: {
status = 403;
break;
}
case 'ModelNotFound':
case 404: {
status = 404;
break;
}
case 'ComfyUIServiceUnavailable':
case 503: {
status = 503;
break;
}
default: {
status = 500;
}
}
return Response.json(agentError, { status });
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return Response.json({ error: errorMessage }, { status: 500 });
}
};
export const comfyUICreateImageAPIHandler = async (request: Request) => {
if (serverDBEnv.KEY_VAULTS_SECRET) {
const authorization = request.headers.get('Authorization');
if (authorization === `Bearer ${serverDBEnv.KEY_VAULTS_SECRET}`) {
return handleComfyUICreateImage(request, { jwtPayload: { userId: 'INTERNAL_SERVICE' } });
}
}
return checkAuth(handleComfyUICreateImage)(request, {
params: Promise.resolve({ provider: 'comfyui' }),
});
};
+37
View File
@@ -0,0 +1,37 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
const TRACING_DIR = '.agent-tracing';
export const agentTracingAPIHandler = async (request: Request) => {
if (process.env.NODE_ENV !== 'development') {
return Response.json({ error: 'dev only' }, { status: 404 });
}
const url = new URL(request.url);
const file = url.searchParams.get('file');
const root = path.resolve(process.cwd(), TRACING_DIR);
if (file) {
const safe = path.basename(file);
const fullPath = path.join(root, safe);
try {
const content = await fs.readFile(fullPath, 'utf8');
return new Response(content, {
headers: { 'content-type': 'application/json' },
});
} catch {
return Response.json({ error: 'not found' }, { status: 404 });
}
}
try {
const files = await fs.readdir(root);
const items = files.filter((item) => item.endsWith('.json') && item !== 'latest.json');
return Response.json({ files: items });
} catch {
return Response.json({ files: [] });
}
};
+53
View File
@@ -0,0 +1,53 @@
import debug from 'debug';
import { FileModel } from '@/database/models/file';
import { getServerDB } from '@/database/server';
import { FileService } from '@/server/services/file';
const log = debug('lobe-file:proxy');
/**
* File proxy service
* GET /f/:id
*
* Features:
* - Query database to get file record (without userId filter for public access)
* - Generate a temporary S3 presigned preview URL (cached internally)
* - Return 302 redirect
*/
export const fileProxyAPIHandler = async (
_request: Request,
params: { id: string },
): Promise<Response> => {
try {
const { id } = params;
log('File proxy request: %s', id);
const db = await getServerDB();
// Query file record without userId filter (public access)
const file = await FileModel.getFileById(db, id);
if (!file) {
log('File not found: %s', id);
return new Response('File not found', {
status: 404,
});
}
// Create file service with file owner's userId
const fileService = new FileService(db, file.userId);
// Web: Generate a cached S3 presigned URL, normalizing legacy full S3 URLs.
const redirectUrl = await fileService.createCachedPreSignedUrlForPreview(file.url);
log('Web S3 presigned URL generated');
return Response.redirect(redirectUrl, 302);
} catch (error) {
console.error('File proxy error:', error);
return new Response('Internal server error', {
status: 500,
});
}
};
+723
View File
@@ -0,0 +1,723 @@
import { getTrustedClientTokenForSession } from '@/libs/trusted-client';
import { MarketService } from '@/server/services/market';
const MARKET_BASE_URL = process.env.MARKET_BASE_URL || 'https://market.lobehub.com';
const ALLOWED_OIDC_ENDPOINTS = new Set(['handoff', 'token', 'userinfo']);
const methodNotAllowed = (methods: string[]) =>
Response.json(
{
error: 'method_not_allowed',
message: `Allowed methods: ${methods.join(', ')}`,
status: 'error',
},
{
headers: { Allow: methods.join(', ') },
status: 405,
},
);
const badRequest = (error: string, message: string) =>
Response.json(
{
error,
message,
status: 'error',
},
{ status: 400 },
);
const notFound = (reason: string) =>
Response.json(
{
error: 'not_found',
message: reason,
status: 'error',
},
{ status: 404 },
);
export interface MarketSegmentsParams {
segments?: string[];
}
interface PaginationParams {
limit?: number;
offset?: number;
}
const createPaginationParams = (request: Request): PaginationParams => {
const url = new URL(request.url);
const limit = url.searchParams.get('pageSize') || url.searchParams.get('limit');
const offset = url.searchParams.get('offset');
return {
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
};
};
const resolveTargetValue = (targetIdOrIdentifier: string) => {
const isNumeric = /^\d+$/.test(targetIdOrIdentifier);
return isNumeric ? Number(targetIdOrIdentifier) : targetIdOrIdentifier;
};
const resolveBodyTargetValue = (payload: { identifier?: string; targetId?: number }) =>
(payload.identifier ?? payload.targetId) as string;
const readCount = (response: unknown): number => {
if (typeof response !== 'object' || response === null) return 0;
const counts = response as Partial<Record<'total' | 'totalCount', unknown>>;
if (typeof counts.totalCount === 'number') return counts.totalCount;
if (typeof counts.total === 'number') return counts.total;
return 0;
};
const ensureOIDCEndpoint = (segments?: string[]) => {
if (!segments || segments.length === 0) {
return { error: 'missing_endpoint', status: 404 } as const;
}
if (segments.length !== 1) {
return { error: 'unsupported_nested_path', status: 404 } as const;
}
const endpoint = segments[0];
if (!ALLOWED_OIDC_ENDPOINTS.has(endpoint)) {
return { error: 'unknown_endpoint', status: 404 } as const;
}
return { endpoint } as const;
};
export interface MarketUserProfileParams {
username: string;
}
export const marketUserProfileAPIHandler = async (
request: Request,
params: MarketUserProfileParams,
) => {
const decodedUsername = decodeURIComponent(params.username);
const marketService = await MarketService.createFromRequest(request);
const { market } = marketService;
try {
const response = await market.user.getUserInfo(decodedUsername);
if (!response?.user) {
return Response.json(
{
error: 'user_not_found',
message: `User not found: ${decodedUsername}`,
status: 'error',
},
{ status: 404 },
);
}
const { user } = response;
return Response.json({
avatarUrl: user.avatarUrl || null,
bannerUrl: user.meta?.bannerUrl || null,
createdAt: user.createdAt,
description: user.meta?.description || null,
displayName: user.displayName || null,
id: user.id,
namespace: user.namespace,
socialLinks: user.meta?.socialLinks || null,
type: user.type || null,
userName: user.userName || null,
});
} catch (error) {
console.error('[Market] Failed to get user profile:', error);
return Response.json(
{
error: 'get_user_profile_failed',
message: error instanceof Error ? error.message : 'Unknown error',
status: 'error',
},
{ status: 500 },
);
}
};
export const marketUserMeAPIHandler = async (request: Request) => {
const marketService = await MarketService.createFromRequest(request);
const { market } = marketService;
try {
const payload = await request.json();
if (typeof payload !== 'object' || payload === null) {
return Response.json(
{
error: 'invalid_payload',
message: 'Request body must be a JSON object',
status: 'error',
},
{ status: 400 },
);
}
const normalizedPayload = {
...payload,
meta: payload.meta ?? {},
};
const response = await market.user.updateUserInfo(normalizedPayload);
return Response.json(response);
} catch (error) {
console.error('[Market] Failed to update user profile:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const isUserNameTaken = errorMessage.toLowerCase().includes('already taken');
return Response.json(
{
error: isUserNameTaken ? 'username_taken' : 'update_user_profile_failed',
message: errorMessage,
status: 'error',
},
{ status: isUserNameTaken ? 409 : 500 },
);
}
};
export const marketAgentAPIHandler = async (
request: Request,
{ segments }: MarketSegmentsParams,
) => {
const normalizedSegments = segments?.map((segment) => decodeURIComponent(segment)) ?? [];
if (normalizedSegments.length === 0) {
return notFound('Missing agent action.');
}
const [action, ...rest] = normalizedSegments;
const marketService = await MarketService.createFromRequest(request);
const { market } = marketService;
if (action === 'create') {
if (request.method !== 'POST') return methodNotAllowed(['POST']);
try {
const payload = await request.json();
const response = await market.agents.createAgent(payload);
return Response.json(response);
} catch (error) {
console.error('[Market] Failed to create agent:', error);
return Response.json(
{
error: 'create_agent_failed',
message: error instanceof Error ? error.message : 'Unknown error',
status: 'error',
},
{ status: 500 },
);
}
}
if (action === 'own') {
if (request.method !== 'GET') return methodNotAllowed(['GET']);
try {
const url = new URL(request.url);
const page = url.searchParams.get('page');
const pageSize = url.searchParams.get('pageSize');
const response = await market.agents.getOwnAgents({
page: page ? Number.parseInt(page, 10) : undefined,
pageSize: pageSize ? Number.parseInt(pageSize, 10) : undefined,
});
return Response.json(response);
} catch (error) {
console.error('[Market] Failed to get own agents:', error);
return Response.json(
{
error: 'get_own_agents_failed',
message: error instanceof Error ? error.message : 'Unknown error',
status: 'error',
},
{ status: 500 },
);
}
}
if (action === 'versions') {
if (rest.length !== 1 || rest[0] !== 'create') {
return notFound('Requested agent version endpoint is not available.');
}
if (request.method !== 'POST') return methodNotAllowed(['POST']);
try {
const payload = await request.json();
if (typeof payload !== 'object' || payload === null) {
return badRequest('invalid_payload', 'Request body must be a JSON object.');
}
const identifier = (payload as { identifier?: string }).identifier;
if (!identifier) {
return badRequest('missing_identifier', 'Identifier is required to create agent version.');
}
const response = await market.agents.createAgentVersion(payload);
return Response.json(response);
} catch (error) {
console.error('[Market] Failed to create agent version:', error);
return Response.json(
{
error: 'create_agent_version_failed',
message: error instanceof Error ? error.message : 'Unknown error',
status: 'error',
},
{ status: 500 },
);
}
}
if (normalizedSegments.length === 2) {
const [identifier, statusAction] = normalizedSegments;
if (!['publish', 'unpublish', 'deprecate'].includes(statusAction)) {
return notFound(`Unknown agent action: ${statusAction}`);
}
if (request.method !== 'POST') return methodNotAllowed(['POST']);
try {
let response;
switch (statusAction) {
case 'publish': {
response = await market.agents.publish(identifier);
break;
}
case 'unpublish': {
response = await market.agents.unpublish(identifier);
break;
}
case 'deprecate': {
response = await market.agents.deprecate(identifier);
break;
}
}
return Response.json(response ?? { success: true });
} catch (error) {
console.error(`[Market] Failed to ${statusAction} agent:`, error);
return Response.json(
{
error: `${statusAction}_agent_failed`,
message: error instanceof Error ? error.message : 'Unknown error',
status: 'error',
},
{ status: 500 },
);
}
}
if (normalizedSegments.length === 1) {
if (request.method !== 'GET') return methodNotAllowed(['GET']);
try {
const response = await market.agents.getAgentDetail(action);
return Response.json(response);
} catch (error) {
console.error('[Market] Failed to get agent detail:', error);
return Response.json(
{
error: 'get_agent_detail_failed',
message: error instanceof Error ? error.message : 'Unknown error',
status: 'error',
},
{ status: 500 },
);
}
}
return notFound('Requested agent endpoint is not available.');
};
export const marketOIDCAPIHandler = async (
request: Request,
{ segments }: MarketSegmentsParams,
) => {
const endpointResult = ensureOIDCEndpoint(segments);
if ('error' in endpointResult) {
return Response.json(
{
error: endpointResult.error,
message: 'Requested endpoint is not available.',
status: 'error',
},
{ status: endpointResult.status },
);
}
const marketService = new MarketService();
const { market } = marketService;
const endpoint = endpointResult.endpoint;
switch (endpoint) {
case 'handoff': {
try {
const id = new URL(request.url).searchParams.get('id');
if (id) {
const handoff = await market.auth.getOAuthHandoff(id);
return new Response(JSON.stringify(handoff), { status: 200 });
}
return Response.json(
{
error: 'missing_id',
message: 'ID is required for handoff proxy.',
status: 'error',
},
{ status: 400 },
);
} catch (error) {
return Response.json(
{
error: 'handoff_proxy_failed',
message: error instanceof Error ? error.message : 'Unknown error',
status: 'error',
},
{ status: 500 },
);
}
}
case 'token': {
if (request.method !== 'POST') return methodNotAllowed(['POST']);
try {
const body = await request.text();
const form = new URLSearchParams(body);
const grantType = (form.get('grant_type') || 'authorization_code') as
| 'authorization_code'
| 'refresh_token';
if (grantType === 'authorization_code') {
const response = await market.auth.exchangeOAuthToken({
clientId: form.get('client_id') as string,
code: form.get('code') as string,
codeVerifier: form.get('code_verifier') as string,
grantType: 'authorization_code',
redirectUri: form.get('redirect_uri') as string,
});
return Response.json(response);
}
if (grantType === 'refresh_token') {
const refreshToken = form.get('refresh_token');
const clientId = form.get('client_id');
const response = await market.auth.exchangeOAuthToken({
clientId: clientId ?? undefined,
grantType: 'refresh_token',
refreshToken: refreshToken as string,
});
return Response.json(response);
}
return Response.json(
{
error: 'unsupported_grant_type',
message: `Unsupported grant_type: ${grantType}`,
status: 'error',
},
{ status: 400 },
);
} catch (error) {
console.error('[MarketOIDC] Failed to proxy token request:', error);
return Response.json(
{
error: 'token_proxy_failed',
message: error instanceof Error ? error.message : 'Unknown error',
status: 'error',
},
{ status: 500 },
);
}
}
case 'userinfo': {
if (request.method !== 'POST') return methodNotAllowed(['POST']);
try {
const { token } = (await request.json()) as { token?: string };
if (!token) {
const trustedClientToken = await getTrustedClientTokenForSession();
if (!trustedClientToken) {
return Response.json(
{
error: 'missing_token',
message: 'Token is required for userinfo proxy.',
status: 'error',
},
{ status: 400 },
);
}
const userInfoUrl = `${MARKET_BASE_URL}/lobehub-oidc/userinfo`;
const response = await fetch(userInfoUrl, {
headers: {
'Content-Type': 'application/json',
'x-lobe-trust-token': trustedClientToken,
},
method: 'GET',
});
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.status} ${response.statusText}`);
}
const userInfo = await response.json();
return Response.json(userInfo);
}
const response = await market.auth.getUserInfo(token);
return Response.json(response);
} catch (error) {
console.error('[MarketOIDC] Failed to proxy userinfo request:', error);
return Response.json(
{
error: 'userinfo_proxy_failed',
message: error instanceof Error ? error.message : 'Unknown error',
status: 'error',
},
{ status: 500 },
);
}
}
}
return Response.json(
{
error: 'unsupported_endpoint',
message: 'Requested endpoint is not supported.',
status: 'error',
},
{ status: 404 },
);
};
export const marketSocialAPIHandler = async (
request: Request,
{ segments }: MarketSegmentsParams,
) => {
const normalizedSegments = segments?.map((segment) => decodeURIComponent(segment)) ?? [];
const action = normalizedSegments[0];
const marketService = await MarketService.createFromRequest(request);
const { market } = marketService;
if (request.method === 'POST') {
try {
const body = await request.json();
switch (action) {
case 'follow': {
const { followingId } = body as { followingId: number };
await market.follows.follow(followingId);
return Response.json({ success: true });
}
case 'unfollow': {
const { followingId } = body as { followingId: number };
await market.follows.unfollow(followingId);
return Response.json({ success: true });
}
case 'favorite': {
const payload = body as {
identifier?: string;
targetId?: number;
targetType: 'agent' | 'plugin';
};
await market.favorites.addFavorite(payload.targetType, resolveBodyTargetValue(payload));
return Response.json({ success: true });
}
case 'unfavorite': {
const payload = body as {
identifier?: string;
targetId?: number;
targetType: 'agent' | 'plugin';
};
await market.favorites.removeFavorite(
payload.targetType,
resolveBodyTargetValue(payload),
);
return Response.json({ success: true });
}
case 'like': {
const payload = body as {
identifier?: string;
targetId?: number;
targetType: 'agent' | 'plugin';
};
await market.likes.like(payload.targetType, resolveBodyTargetValue(payload));
return Response.json({ success: true });
}
case 'unlike': {
const payload = body as {
identifier?: string;
targetId?: number;
targetType: 'agent' | 'plugin';
};
await market.likes.unlike(payload.targetType, resolveBodyTargetValue(payload));
return Response.json({ success: true });
}
case 'toggle-like': {
const payload = body as {
identifier?: string;
targetId?: number;
targetType: 'agent' | 'plugin';
};
const result = await market.likes.toggleLike(
payload.targetType,
resolveBodyTargetValue(payload),
);
return Response.json(result);
}
default: {
return Response.json(
{ error: 'not_found', message: `Unknown action: ${action}` },
{ status: 404 },
);
}
}
} catch (error) {
console.error('[Market Social] Action failed:', error);
return Response.json(
{
error: 'action_failed',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}
if (request.method !== 'GET') return methodNotAllowed(['GET', 'POST']);
const paginationParams = createPaginationParams(request);
try {
switch (action) {
case 'follow-status': {
const targetUserId = Number(normalizedSegments[1]);
const result = await market.follows.checkFollowStatus(targetUserId);
return Response.json(result);
}
case 'following': {
const userId = Number(normalizedSegments[1]);
const result = await market.follows.getFollowing(userId, paginationParams);
return Response.json(result);
}
case 'followers': {
const userId = Number(normalizedSegments[1]);
const result = await market.follows.getFollowers(userId, paginationParams);
return Response.json(result);
}
case 'follow-counts': {
const userId = Number(normalizedSegments[1]);
const [following, followers] = await Promise.all([
market.follows.getFollowing(userId, { limit: 1 }),
market.follows.getFollowers(userId, { limit: 1 }),
]);
return Response.json({
followersCount: readCount(followers),
followingCount: readCount(following),
});
}
case 'favorite-status': {
const targetType = normalizedSegments[1] as 'agent' | 'plugin';
const targetValue = resolveTargetValue(normalizedSegments[2]);
const result = await market.favorites.checkFavorite(targetType, targetValue as number);
return Response.json(result);
}
case 'favorites': {
const result = await market.favorites.getMyFavorites(paginationParams);
return Response.json(result);
}
case 'user-favorites': {
const userId = Number(normalizedSegments[1]);
const result = await market.favorites.getUserFavorites(userId, paginationParams);
return Response.json(result);
}
case 'favorite-agents': {
const userId = Number(normalizedSegments[1]);
const result = await market.favorites.getUserFavoriteAgents(userId, paginationParams);
return Response.json(result);
}
case 'favorite-plugins': {
const userId = Number(normalizedSegments[1]);
const result = await market.favorites.getUserFavoritePlugins(userId, paginationParams);
return Response.json(result);
}
case 'like-status': {
const targetType = normalizedSegments[1] as 'agent' | 'plugin';
const targetValue = resolveTargetValue(normalizedSegments[2]);
const result = await market.likes.checkLike(targetType, targetValue as number);
return Response.json(result);
}
case 'liked-agents': {
const userId = Number(normalizedSegments[1]);
const result = await market.likes.getUserLikedAgents(userId, paginationParams);
return Response.json(result);
}
case 'liked-plugins': {
const userId = Number(normalizedSegments[1]);
const result = await market.likes.getUserLikedPlugins(userId, paginationParams);
return Response.json(result);
}
default: {
return Response.json(
{ error: 'not_found', message: `Unknown action: ${action}` },
{ status: 404 },
);
}
}
} catch (error) {
console.error('[Market Social] Query failed:', error);
return Response.json(
{
error: 'query_failed',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
};
@@ -0,0 +1,215 @@
import { DEFAULT_USER_MEMORY_EMBEDDING_MODEL_ITEM } from '@lobechat/const';
import { ModelRuntime } from '@lobechat/model-runtime';
import { and, eq, inArray } from 'drizzle-orm';
import { z } from 'zod';
import { UserMemoryModel } from '@/database/models/userMemory/model';
import { userMemories } from '@/database/schemas';
import { getServerDB } from '@/database/server';
import { selectNonVectorColumns } from '@/database/utils/columns';
import { parseMemoryExtractionConfig } from '@/server/globalConfig/parseMemoryExtractionConfig';
import { embedUserMemoryTexts } from '@/server/services/memory/userMemory/embedding';
import { LayersEnum } from '@/types/userMemory';
const bodySchema = z.object({
layer: z.nativeEnum(LayersEnum).optional(),
query: z.string().min(1),
sampleId: z.string().optional(),
topK: z.coerce.number().int().positive().max(50).optional(),
userId: z.string().optional(),
});
export const memoryUserMemoryBenchmarkLoCoMoDevAPIHandler = async (request: Request) => {
const { featureFlags, webhook } = parseMemoryExtractionConfig();
if (!featureFlags.enableBenchmarkLoCoMo) {
return Response.json({ error: 'Not found' }, { status: 404 });
}
if (webhook?.headers && Object.keys(webhook?.headers).length > 0) {
for (const [key, value] of Object.entries(webhook?.headers)) {
const headerValue = request.headers.get(key);
if (headerValue !== value) {
return Response.json(
{ error: `Unauthorized: Missing or invalid header '${key}'` },
{ status: 403 },
);
}
}
}
try {
const json = await request.json();
const parsed = bodySchema.parse(json);
console.info('[locomo-dev-search] parsed body', parsed);
const userId =
parsed.userId || (parsed.sampleId ? `locomo-user-${parsed.sampleId}` : undefined);
if (!userId) {
return Response.json({ error: 'userId or sampleId is required' }, { status: 400 });
}
const topK = parsed.topK ?? 5;
const db = await getServerDB();
const model = new UserMemoryModel(db, userId);
const config = parseMemoryExtractionConfig();
const runtime = await ModelRuntime.initializeWithProvider(
DEFAULT_USER_MEMORY_EMBEDDING_MODEL_ITEM.provider,
{
apiKey: config.embedding.apiKey,
baseURL: config.embedding.baseURL,
},
);
const [embedding] = await embedUserMemoryTexts({
input: [parsed.query],
model: config.embedding.model,
runtime,
source: 'dev:locomo.search',
userId,
});
if (!embedding) {
return Response.json({ error: 'Failed to generate embedding for query' }, { status: 500 });
}
console.info('[locomo-dev-search] generated embedding');
const searchResult = await model.searchWithEmbedding({
embedding,
limits: {
activities: topK,
contexts: topK,
experiences: topK,
preferences: topK,
},
});
console.info('[locomo-dev-search] searched result');
const identities = await model.getAllIdentities();
console.info('[locomo-dev-search] fetched identities');
const memoryIds = [
...searchResult.contexts
.map((context) =>
Array.isArray(context.userMemoryIds) ? (context.userMemoryIds as string[])[0] : undefined,
)
.filter((id): id is string => !!id),
...searchResult.experiences
.map((experience) => experience.userMemoryId)
.filter((id): id is string => !!id),
...searchResult.preferences
.map((preference) => preference.userMemoryId)
.filter((id): id is string => !!id),
...searchResult.activities
.map((activity) => activity.userMemoryId)
.filter((id): id is string => !!id),
...identities.map((identity) => identity.userMemoryId).filter((id): id is string => !!id),
];
const uniqueMemoryIds = Array.from(new Set(memoryIds));
const memories =
uniqueMemoryIds.length === 0
? []
: await db
.select(selectNonVectorColumns(userMemories))
.from(userMemories)
.where(and(eq(userMemories.userId, userId), inArray(userMemories.id, uniqueMemoryIds)));
console.info('[locomo-dev-search] fetched memories');
const memoryMap = new Map(memories.map((memory) => [memory.id, memory]));
const contextItems = searchResult.contexts
.map((context) => {
const memoryId = Array.isArray(context.userMemoryIds)
? (context.userMemoryIds as string[])[0]
: undefined;
const memory = memoryId ? memoryMap.get(memoryId) : undefined;
if (!memory) return undefined;
return {
context,
id: memory.id,
layer: LayersEnum.Context,
memory,
};
})
.filter(Boolean);
const experienceItems = searchResult.experiences
.map((experience) => {
const memory = experience.userMemoryId ? memoryMap.get(experience.userMemoryId) : undefined;
if (!memory) return undefined;
return {
experience,
id: experience.userMemoryId,
layer: LayersEnum.Experience,
memory,
};
})
.filter(Boolean);
const preferenceItems = searchResult.preferences
.map((preference) => {
const memory = preference.userMemoryId ? memoryMap.get(preference.userMemoryId) : undefined;
if (!memory) return undefined;
return {
id: preference.userMemoryId,
layer: LayersEnum.Preference,
memory,
preference,
};
})
.filter(Boolean);
const identityItems = identities
.map((identity) => {
const memory = identity.userMemoryId ? memoryMap.get(identity.userMemoryId) : undefined;
if (!memory) return undefined;
return {
id: identity.userMemoryId,
identity,
layer: LayersEnum.Identity,
memory,
};
})
.filter(Boolean);
const activityItems = searchResult.activities
.map((activity) => {
const memory = activity.userMemoryId ? memoryMap.get(activity.userMemoryId) : undefined;
if (!memory) return undefined;
return {
activity,
id: activity.userMemoryId,
layer: LayersEnum.Activity,
memory,
};
})
.filter(Boolean);
const items = [
...contextItems.slice(0, topK),
...experienceItems.slice(0, topK),
...preferenceItems.slice(0, topK),
...activityItems.slice(0, topK),
...identityItems,
];
console.info('[locomo-dev-search] compiled items');
return Response.json({
items,
total: items.length,
userId,
});
} catch (error) {
console.error('[locomo-dev-search] failed', error);
return Response.json({ error: (error as Error).message }, { status: 500 });
}
};
@@ -0,0 +1,281 @@
import type { UserMemoryExtractionMetadata } from '@lobechat/types';
import {
AsyncTaskError,
AsyncTaskErrorType,
AsyncTaskStatus,
AsyncTaskType,
} from '@lobechat/types';
import { Client as WorkflowClient } from '@upstash/workflow';
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
import { AsyncTaskModel, initUserMemoryExtractionMetadata } from '@/database/models/asyncTask';
import { asyncTasks } from '@/database/schemas';
import { getServerDB } from '@/database/server';
import { parseMemoryExtractionConfig } from '@/server/globalConfig/parseMemoryExtractionConfig';
import {
buildWorkflowPayloadInput,
MemoryExtractionExecutor,
memoryExtractionPayloadSchema,
MemoryExtractionWorkflowService,
normalizeMemoryExtractionPayload,
} from '@/server/services/memory/userMemory/extract';
import {
buildUserPersonaJobInput,
UserPersonaService,
} from '@/server/services/memory/userMemory/persona/service';
const userPersonaWebhookSchema = z.object({
baseUrl: z.string().url().optional(),
mode: z.enum(['workflow', 'direct']).optional(),
userId: z.string().optional(),
userIds: z.array(z.string()).optional(),
});
const cancelPayloadSchema = z.object({
reason: z.string().trim().max(1000).optional(),
taskId: z.string().uuid(),
userId: z.string().optional(),
workflowRunId: z.string().optional(),
workflowRunIds: z.array(z.string()).optional(),
});
type UserPersonaWebhookPayload = z.infer<typeof userPersonaWebhookSchema>;
const normalizeUserPersonaPayload = (
payload: UserPersonaWebhookPayload,
fallbackBaseUrl?: string,
) => {
const parsed = userPersonaWebhookSchema.parse(payload);
const baseUrl = parsed.baseUrl || fallbackBaseUrl;
if (!baseUrl) throw new Error('Missing baseUrl for workflow trigger');
return {
baseUrl,
mode: parsed.mode ?? 'workflow',
userIds: Array.from(
new Set([...(parsed.userIds || []), ...(parsed.userId ? [parsed.userId] : [])]),
).filter(Boolean),
} as const;
};
const verifyMemoryWebhookHeaders = (request: Request, headers?: Record<string, string>) => {
if (!headers || Object.keys(headers).length === 0) return;
for (const [key, value] of Object.entries(headers)) {
const headerValue = request.headers.get(key);
if (headerValue !== value) {
return Response.json(
{ error: `Unauthorized: Missing or invalid header '${key}'` },
{ status: 403 },
);
}
}
};
const getWorkflowClient = () => {
const token = process.env.QSTASH_TOKEN;
if (!token) throw new Error('QSTASH_TOKEN is required to cancel workflow runs');
const config: ConstructorParameters<typeof WorkflowClient>[0] = { token };
if (process.env.QSTASH_URL) {
(config as Record<string, unknown>).url = process.env.QSTASH_URL;
}
return new WorkflowClient(config);
};
export const memoryExtractionWebhookAPIHandler = async (request: Request) => {
const { webhook, upstashWorkflowExtraHeaders } = parseMemoryExtractionConfig();
const unauthorizedResponse = verifyMemoryWebhookHeaders(request, webhook.headers);
if (unauthorizedResponse) return unauthorizedResponse;
try {
const json = await request.json();
const origin = new URL(request.url).origin;
const payload = memoryExtractionPayloadSchema.parse({
...json,
baseUrl: json.baseUrl || origin,
});
if (payload.fromDate && payload.toDate && payload.fromDate > payload.toDate) {
return Response.json({ error: '`fromDate` cannot be later than `toDate`' }, { status: 400 });
}
const params = normalizeMemoryExtractionPayload(payload, origin);
if (params.mode === 'workflow') {
const { workflowRunId } = await MemoryExtractionWorkflowService.triggerProcessUsers(
buildWorkflowPayloadInput(params),
{ extraHeaders: upstashWorkflowExtraHeaders },
);
return Response.json(
{ message: 'Memory extraction scheduled via workflow.', workflowRunId },
{ status: 202 },
);
}
const executor = await MemoryExtractionExecutor.create();
const result = await executor.runDirect(params);
return Response.json(
{ message: 'Memory extraction executed via webhook.', result },
{ status: 200 },
);
} catch (error) {
console.error('[memory-extraction] failed', error);
return Response.json({ error: (error as Error).message }, { status: 500 });
}
};
export const memoryUserPersonaUpdateWritingWebhookAPIHandler = async (request: Request) => {
const { upstashWorkflowExtraHeaders, webhook } = parseMemoryExtractionConfig();
const unauthorizedResponse = verifyMemoryWebhookHeaders(request, webhook.headers);
if (unauthorizedResponse) return unauthorizedResponse;
try {
const json = await request.json();
const origin = new URL(request.url).origin;
const params = normalizeUserPersonaPayload(json, webhook.baseUrl || origin);
if (params.userIds.length === 0) {
return Response.json({ error: 'userId or userIds is required' }, { status: 400 });
}
if (params.mode === 'workflow') {
const results = await Promise.all(
params.userIds.map(async (userId) => {
const { workflowRunId } = await MemoryExtractionWorkflowService.triggerPersonaUpdate(
userId,
params.baseUrl,
{ extraHeaders: upstashWorkflowExtraHeaders },
);
return { userId, workflowRunId };
}),
);
return Response.json(
{ message: 'User persona update scheduled via workflow.', results },
{ status: 202 },
);
}
const db = await getServerDB();
const service = new UserPersonaService(db);
const results = [];
for (const userId of params.userIds) {
const context = await buildUserPersonaJobInput(db, userId);
const result = await service.composeWriting({ ...context, userId });
results.push({ userId, ...result });
}
return Response.json(
{ message: 'User persona generated via webhook.', results },
{ status: 200 },
);
} catch (error) {
console.error('[user-persona] failed', error);
return Response.json({ error: (error as Error).message }, { status: 500 });
}
};
export const memoryExtractChatTopicCancelWebhookAPIHandler = async (request: Request) => {
const { webhook } = parseMemoryExtractionConfig();
const unauthorizedResponse = verifyMemoryWebhookHeaders(request, webhook.headers);
if (unauthorizedResponse) return unauthorizedResponse;
try {
const payload = cancelPayloadSchema.parse(await request.json());
const db = await getServerDB();
const task = await db.query.asyncTasks.findFirst({
where: and(
eq(asyncTasks.id, payload.taskId),
eq(asyncTasks.type, AsyncTaskType.UserMemoryExtractionWithChatTopic),
),
});
if (!task) {
return Response.json(
{ error: `Memory extraction task not found for id '${payload.taskId}'` },
{ status: 404 },
);
}
if (payload.userId && payload.userId !== task.userId) {
return Response.json(
{ error: `Task '${payload.taskId}' does not belong to the provided userId` },
{ status: 403 },
);
}
const metadata = initUserMemoryExtractionMetadata(
task.metadata as UserMemoryExtractionMetadata | undefined,
);
const workflowRunIds = Array.from(
new Set([
...(metadata.control?.upstash?.workflowRunIds || []),
...(payload.workflowRunId ? [payload.workflowRunId] : []),
...(payload.workflowRunIds || []),
]),
);
const nextMetadata: UserMemoryExtractionMetadata = {
...metadata,
control: {
cancelReason: payload.reason || metadata.control?.cancelReason,
cancelRequestedAt: metadata.control?.cancelRequestedAt || new Date().toISOString(),
cancelledBy: 'webhook',
upstash: {
workflowRunIds,
},
},
};
const asyncTaskModel = new AsyncTaskModel(db, task.userId);
await asyncTaskModel.update(task.id, {
error: new AsyncTaskError(
AsyncTaskErrorType.TaskCancelled,
payload.reason || 'Memory extraction cancelled from webhook',
),
metadata: nextMetadata,
status: AsyncTaskStatus.Error,
});
let cancelledWorkflowRuns = 0;
if (workflowRunIds.length > 0) {
try {
const result = await getWorkflowClient().cancel({ ids: workflowRunIds });
cancelledWorkflowRuns = result.cancelled || 0;
} catch (error) {
console.error(
'[memory-user-memory/pipelines/extract/chat-topic/cancel] failed to cancel workflow runs',
error,
);
}
}
return Response.json(
{
cancelledWorkflowRuns,
message: 'Memory extraction cancellation has been requested.',
status: AsyncTaskStatus.Error,
taskId: task.id,
},
{ status: 200 },
);
} catch (error) {
console.error('[memory-user-memory/pipelines/extract/chat-topic/cancel] failed', error);
return Response.json({ error: (error as Error).message }, { status: 500 });
}
};
@@ -0,0 +1,184 @@
import { BenchmarkLocomoContextProvider } from '@lobechat/memory-user-memory';
import { MemorySourceType } from '@lobechat/types';
import { z } from 'zod';
import { UserMemorySourceBenchmarkLoCoMoModel } from '@/database/models/userMemory/sources/benchmarkLoCoMo';
import { parseMemoryExtractionConfig } from '@/server/globalConfig/parseMemoryExtractionConfig';
import { MemoryExtractionExecutor } from '@/server/services/memory/userMemory/extract';
import { LayersEnum } from '@/types/userMemory';
const turnSchema = z.object({
createdAt: z.string(),
diaId: z.string().optional(),
imageCaption: z.string().optional(),
imageUrls: z.array(z.string()).optional(),
role: z.string().optional(),
speaker: z.string(),
text: z.string(),
});
const sessionSchema = z.object({
sessionId: z.string(),
timestamp: z.string().optional(),
turns: z.array(turnSchema),
});
const ingestSchema = z.object({
force: z.boolean().optional(),
layers: z.array(z.string()).optional(),
sampleId: z.string(),
sessions: z.array(sessionSchema),
source: z.nativeEnum(MemorySourceType).optional(),
sourceId: z.string().optional(),
userId: z.string(),
});
const normalizeLayers = (layers?: string[]) => {
if (!layers?.length) return [] as LayersEnum[];
const set = new Set<LayersEnum>();
for (const layer of layers) {
const normalized = layer.toLowerCase() as LayersEnum;
if (Object.values(LayersEnum).includes(normalized)) {
set.add(normalized);
}
}
return Array.from(set);
};
interface SessionExtractionResult {
extraction?: Awaited<ReturnType<MemoryExtractionExecutor['extractBenchmarkSource']>>;
insertedParts: number;
sessionId: string;
sourceId: string;
}
export const memoryExtractionBenchmarkLoCoMoWebhookAPIHandler = async (request: Request) => {
try {
const { webhook } = parseMemoryExtractionConfig();
if (webhook.headers && Object.keys(webhook.headers).length > 0) {
for (const [key, value] of Object.entries(webhook.headers)) {
const headerValue = request.headers.get(key);
if (headerValue !== value) {
return Response.json(
{ error: `Unauthorized: Missing or invalid header '${key}'` },
{ status: 403 },
);
}
}
}
const json = await request.json();
const parsed = ingestSchema.parse(json);
const sourceModel = new UserMemorySourceBenchmarkLoCoMoModel(parsed.userId);
const baseSourceId = parsed.sourceId || `sample_${parsed.sampleId}`;
const executor = await MemoryExtractionExecutor.create();
const layers = normalizeLayers(parsed.layers);
const results: SessionExtractionResult[] = await Promise.all(
parsed.sessions.map(async (session) => {
const sessionSourceId = `${baseSourceId}_${session.sessionId}`;
try {
await sourceModel.upsertSource({
id: sessionSourceId,
metadata: {
ingestAt: new Date().toISOString(),
sessionId: session.sessionId,
sessionTimestamp: session.timestamp,
},
sampleId: parsed.sampleId,
sourceType: (parsed.source ?? MemorySourceType.BenchmarkLocomo) as string,
});
} catch (error) {
console.error(
`[locomo-ingest-webhook] upsertSource failed for sourceId=${sessionSourceId}`,
error,
);
return {
extraction: undefined,
insertedParts: 0,
sessionId: session.sessionId,
sourceId: sessionSourceId,
};
}
const parts = session.turns.map((turn, index) => {
const createdAt = new Date(turn.createdAt);
const metadata: Record<string, unknown> = {
diaId: turn.diaId,
imageCaption: turn.imageCaption,
imageUrls: turn.imageUrls,
sessionId: session.sessionId,
};
return {
content: turn.text,
createdAt,
metadata,
partIndex: index,
sessionId: session.sessionId,
speaker: turn.speaker,
};
});
sourceModel.replaceParts(sessionSourceId, parts);
const contextProvider = new BenchmarkLocomoContextProvider({
parts,
sampleId: parsed.sampleId,
sourceId: sessionSourceId,
userId: parsed.userId,
});
try {
const extraction = await executor.extractBenchmarkSource({
contextProvider,
forceAll: parsed.force ?? true,
layers,
parts,
source: parsed.source ?? MemorySourceType.BenchmarkLocomo,
sourceId: sessionSourceId,
userId: parsed.userId,
});
return {
extraction,
insertedParts: parts.length,
sessionId: session.sessionId,
sourceId: sessionSourceId,
};
} catch (error) {
console.error(
`[locomo-ingest-webhook] extractBenchmarkSource failed for sourceId=${sessionSourceId}`,
error,
);
return {
extraction: undefined,
insertedParts: parts.length,
sessionId: session.sessionId,
sourceId: sessionSourceId,
};
}
}),
);
const totalInsertedParts = results.reduce((total, result) => total + result.insertedParts, 0);
return Response.json(
{
baseSourceId,
insertedParts: totalInsertedParts,
results,
sourceIds: results.map((item) => item.sourceId),
userId: parsed.userId,
},
{ status: 200 },
);
} catch (error) {
console.error('[locomo-ingest-webhook] failed', error);
return Response.json({ error: (error as Error).message }, { status: 500 });
}
};
+66
View File
@@ -0,0 +1,66 @@
import type { ChatCompletionErrorPayload, PullModelParams } from '@lobechat/model-runtime';
import { ChatErrorType } from '@lobechat/types';
import { checkAuth } from '@/app/(backend)/middleware/auth';
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { createErrorResponse } from '@/utils/errorResponse';
interface ProviderParams {
provider: string;
}
const createProviderParams = (provider: string) => Promise.resolve({ provider });
export const modelsAPIHandler = (request: Request, { provider }: ProviderParams) =>
checkAuth(async (_req, { params, userId, serverDB }) => {
const routeProvider = (await params).provider!;
try {
const agentRuntime = await initModelRuntimeFromDB(serverDB, userId, routeProvider);
const list = await agentRuntime.models();
return Response.json(list);
} catch (error_) {
const {
errorType = ChatErrorType.InternalServerError,
error: errorContent,
...res
} = error_ as ChatCompletionErrorPayload;
const error = errorContent || error_;
console.error(`Route: [${routeProvider}] ${errorType}:`, error);
const sanitizedError =
error instanceof Error ? { message: error.message, name: error.name } : error;
return createErrorResponse(errorType, { error: sanitizedError, ...res, provider });
}
})(request, { params: createProviderParams(provider) });
export const pullModelsAPIHandler = (request: Request, { provider }: ProviderParams) =>
checkAuth(async (authedRequest, { params, userId, serverDB }) => {
const routeProvider = (await params).provider!;
try {
const agentRuntime = await initModelRuntimeFromDB(serverDB, userId, routeProvider);
const data = (await authedRequest.json()) as PullModelParams;
const response = await agentRuntime.pullModel(data, { signal: authedRequest.signal });
if (response) return response;
throw new Error('No response');
} catch (error_) {
const {
errorType = ChatErrorType.InternalServerError,
error: errorContent,
...res
} = error_ as ChatCompletionErrorPayload;
const error = errorContent || error_;
console.error(`Route: [${routeProvider}] ${errorType}:`, error);
return createErrorResponse(errorType, { error, ...res, provider });
}
})(request, { params: createProviderParams(provider) });
+403
View File
@@ -0,0 +1,403 @@
import { oidcSessions } from '@lobechat/database/schemas';
import debug from 'debug';
import { eq } from 'drizzle-orm';
import { auth } from '@/auth';
import { OAuthHandoffModel } from '@/database/models/oauthHandoff';
import { serverDB } from '@/database/server';
import { appEnv } from '@/envs/app';
import { authEnv } from '@/envs/auth';
import { createNodeRequest, createNodeResponse } from '@/libs/oidc-provider/http-adapter';
import { OIDCService } from '@/server/services/oidc';
import { getOIDCProvider } from '@/server/services/oidc/oidcProvider';
import { scheduleAfterResponse } from '@/server/utils/scheduleAfterResponse';
const callbackLog = debug('lobe-oidc:callback:desktop');
const clearSessionLog = debug('lobe-oidc:clear-session');
const consentLog = debug('lobe-oidc:consent');
const handoffLog = debug('lobe-oidc:handoff');
const providerLog = debug('lobe-oidc:route');
type OIDCProviderMiddleware = (
request: Awaited<ReturnType<typeof createNodeRequest>>,
response: ReturnType<typeof createNodeResponse>['nodeResponse'],
next?: (error?: Error) => void,
) => void;
export interface OIDCCallbackDesktopAPIHandlerOptions {
scheduleAfterResponse?: (task: () => Promise<void> | void) => void;
}
const errorPathname = '/oauth/callback/error';
const buildRedirectUrl = (request: Request, pathname: string): URL => {
if (appEnv.APP_URL) {
try {
const baseUrl = new URL(appEnv.APP_URL);
baseUrl.pathname = pathname;
callbackLog('Using APP_URL for redirect: %s', baseUrl.toString());
return baseUrl;
} catch (error) {
callbackLog('Error parsing APP_URL, using fallback: %O', error);
}
}
callbackLog('Warning: APP_URL not configured, using request URL as fallback');
const fallbackUrl = new URL(request.url);
fallbackUrl.pathname = pathname;
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl;
};
const parseCookieHeader = (cookieHeader: string | null): Record<string, string> => {
if (!cookieHeader) return {};
return Object.fromEntries(
cookieHeader
.split(';')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const separatorIndex = part.indexOf('=');
if (separatorIndex < 0) return [part, ''];
return [part.slice(0, separatorIndex), decodeURIComponent(part.slice(separatorIndex + 1))];
}),
);
};
const createExpiredCookie = (name: string) =>
`${name}=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; HttpOnly`;
export const oidcCallbackDesktopAPIHandler = async (
request: Request,
options: OIDCCallbackDesktopAPIHandlerOptions = {},
) => {
try {
const url = new URL(request.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
if (!code || !state) {
callbackLog('Missing code or state in form data');
const errorUrl = buildRedirectUrl(request, errorPathname);
errorUrl.searchParams.set('reason', 'invalid_request');
callbackLog('Redirecting to error URL: %s', errorUrl.toString());
return Response.redirect(errorUrl, 307);
}
callbackLog('Received OIDC callback. state(handoffId): %s', state);
const client = 'desktop';
const payload = { code, state };
const id = state;
const authHandoffModel = new OAuthHandoffModel(serverDB);
await authHandoffModel.create({ client, id, payload });
callbackLog('Handoff record created successfully for id: %s', id);
const successUrl = buildRedirectUrl(request, '/oauth/callback/success');
callbackLog('Request host header: %s', request.headers.get('host'));
callbackLog('Request x-forwarded-host: %s', request.headers.get('x-forwarded-host'));
callbackLog('Request x-forwarded-proto: %s', request.headers.get('x-forwarded-proto'));
callbackLog('Constructed success URL: %s', successUrl.toString());
const schedule = options.scheduleAfterResponse ?? scheduleAfterResponse;
schedule(async () => {
const cleanedCount = await authHandoffModel.cleanupExpired();
callbackLog('Cleaned up %d expired handoff records', cleanedCount);
});
return Response.redirect(successUrl, 307);
} catch (error) {
callbackLog('Error in OIDC callback: %O', error);
const errorUrl = buildRedirectUrl(request, errorPathname);
errorUrl.searchParams.set('reason', 'internal_error');
if (error instanceof Error) {
errorUrl.searchParams.set('errorMessage', error.message);
}
callbackLog('Redirecting to error URL: %s', errorUrl.toString());
return Response.redirect(errorUrl, 307);
}
};
export const oidcClearSessionAPIHandler = async (request: Request) => {
try {
const session = await auth.api.getSession({
headers: Object.fromEntries(request.headers.entries()),
});
const userId = session?.user?.id;
if (!userId) {
return Response.json({ error: 'unauthorized' }, { status: 401 });
}
const cookies = parseCookieHeader(request.headers.get('cookie'));
const sessionId = cookies._session;
if (!sessionId) {
clearSessionLog('No _session cookie found, nothing to clear');
return Response.json({ cleared: false, ok: true });
}
clearSessionLog('Clearing OIDC session %s for user %s', sessionId, userId);
await serverDB.delete(oidcSessions).where(eq(oidcSessions.id, sessionId));
const response = Response.json({ cleared: true, ok: true });
for (const name of ['_session', '_session.sig', '_session.legacy', '_session.legacy.sig']) {
response.headers.append('Set-Cookie', createExpiredCookie(name));
}
clearSessionLog('OIDC session cleared successfully');
return response;
} catch (error) {
clearSessionLog('Error clearing OIDC session: %O', error);
return Response.json({ cleared: false, error: 'internal', ok: true });
}
};
export const oidcConsentAPIHandler = async (request: Request) => {
consentLog('Received POST request for /oidc/consent, URL: %s', request.url);
try {
const formData = await request.formData();
const consent = formData.get('consent') as string;
const uid = formData.get('uid') as string;
consentLog('POST /oauth/consent - uid=%s, choice=%s', uid, consent);
const oidcService = await OIDCService.initialize();
let details;
try {
details = await oidcService.getInteractionDetails(uid);
consentLog(
'Interaction details found - prompt=%s, client=%s',
details.prompt.name,
details.params.client_id,
);
} catch (error) {
consentLog(
'Error: Interaction details not found - %s',
error instanceof Error ? error.message : 'unknown error',
);
if (error instanceof Error && error.message.includes('interaction session not found')) {
return Response.json(
{
error: 'invalid_request',
error_description:
'Authorization session expired or invalid, please restart the authorization flow',
},
{ status: 400 },
);
}
throw error;
}
const { prompt } = details;
let result;
if (consent === 'accept') {
consentLog(`User accepted the request, Handling 'login' prompt`);
const session = await auth.api.getSession({
headers: Object.fromEntries(request.headers.entries()),
});
const userId = session?.user?.id;
consentLog('Obtained userId: %s', userId);
if (details.prompt.name === 'login') {
result = {
login: { accountId: userId, remember: true },
};
} else {
consentLog(`Handling 'consent' prompt`);
const clientId = details.params.client_id as string;
const grant = await oidcService.findOrCreateGrants(userId!, clientId, details.grantId);
const missingOIDCScope = (prompt.details.missingOIDCScope as string[]) || [];
if (missingOIDCScope) {
grant.addOIDCScope(missingOIDCScope.join(' '));
consentLog('Added OIDC scopes to grant: %s', missingOIDCScope.join(' '));
}
const missingOIDCClaims = (prompt.details.missingOIDCClaims as string[]) || [];
if (missingOIDCClaims) {
grant.addOIDCClaims(missingOIDCClaims);
consentLog('Added OIDC claims: %s', missingOIDCClaims.join(' '));
}
const missingResourceScopes =
(prompt.details.missingResourceScopes as Record<string, string[]>) || {};
if (missingResourceScopes) {
for (const [indicator, scopes] of Object.entries(missingResourceScopes)) {
grant.addResourceScope(indicator, scopes.join(' '));
consentLog('Added resource scopes for %s to grant: %s', indicator, scopes.join(' '));
}
}
const newGrantId = await grant.save();
consentLog('Saved grant with ID: %s', newGrantId);
result = { consent: { grantId: newGrantId } };
consentLog('Consent result prepared with grantId');
}
consentLog('User %s the authorization', consent);
} else {
consentLog('User rejected the request');
result = {
error: 'access_denied',
error_description: 'User denied the authorization request',
};
consentLog('User %s the authorization', consent);
}
consentLog('Interaction Result: %O', result);
const internalRedirectUrlString = await oidcService.getInteractionResult(uid, result);
consentLog('OIDC Provider internal redirect URL string: %s', internalRedirectUrlString);
if (appEnv.APP_URL) {
const baseUrl = new URL(appEnv.APP_URL);
const internalUrl = new URL(internalRedirectUrlString);
baseUrl.pathname = internalUrl.pathname;
baseUrl.search = internalUrl.search;
baseUrl.hash = internalUrl.hash;
const finalRedirectUrl = baseUrl;
consentLog('Using APP_URL as base for redirect: %s', finalRedirectUrl.toString());
return Response.redirect(finalRedirectUrl, 303);
}
consentLog('Using internal redirect URL directly: %s', internalRedirectUrlString);
return Response.redirect(new URL(internalRedirectUrlString), 303);
} catch (error) {
console.error('Error processing consent:', error);
return Response.json(
{
error: 'server_error',
error_description: 'Error processing consent',
},
{ status: 500 },
);
}
};
export const oidcProviderAPIHandler = async (request: Request) => {
const requestUrl = new URL(request.url);
providerLog(
`Received ${request.method.toUpperCase()} request: %s %s`,
request.method,
request.url,
);
providerLog('Path: %s, Pathname: %s', requestUrl.pathname, requestUrl.pathname);
let responseCollector;
try {
if (!authEnv.ENABLE_OIDC) {
providerLog('OIDC is not enabled');
return new Response('OIDC is not enabled', { status: 404 });
}
const provider = await getOIDCProvider();
providerLog(`Calling provider.callback() for ${request.method}`);
await new Promise<void>((resolve, reject) => {
let middleware: OIDCProviderMiddleware;
try {
providerLog('Attempting to get middleware from provider.callback()');
middleware = provider.callback() as OIDCProviderMiddleware;
providerLog('Successfully obtained middleware function.');
} catch (syncError) {
providerLog('SYNC ERROR during provider.callback() call itself: %O', syncError);
reject(syncError);
return;
}
responseCollector = createNodeResponse(resolve);
const nodeResponse = responseCollector.nodeResponse;
void createNodeRequest(request).then((nodeRequest) => {
providerLog('Calling the obtained middleware...');
middleware(nodeRequest, nodeResponse, (error?: Error) => {
providerLog('Middleware callback function HAS BEEN EXECUTED.');
if (error) {
providerLog('Middleware error reported via callback: %O', error);
reject(error);
} else {
providerLog(
'Middleware completed successfully via callback (may be redundant if .end() was called).',
);
resolve();
}
});
providerLog('Middleware call initiated, waiting for its callback OR nodeResponse.end()...');
});
});
providerLog('Promise surrounding middleware call resolved.');
if (!responseCollector) {
throw new Error('ResponseCollector was not initialized.');
}
const {
responseBody: finalBody,
responseHeaders: finalHeaders,
responseStatus: finalStatus,
} = responseCollector;
providerLog('Final Response Status: %d', finalStatus);
providerLog('Final Response Headers: %O', finalHeaders);
return new Response(finalBody, {
headers: finalHeaders as HeadersInit,
status: finalStatus,
});
} catch (error) {
providerLog(`Error handling OIDC ${request.method} request: %O`, error);
return new Response(`Internal Server Error: ${(error as Error).message}`, { status: 500 });
}
};
export const oidcHandoffAPIHandler = async (request: Request) => {
handoffLog('Received GET request for /oidc/handoff');
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const client = searchParams.get('client');
if (!id || !client) {
return Response.json(
{ error: 'Missing required parameters: id and client' },
{ status: 400 },
);
}
handoffLog('Fetching handoff record - id=%s, client=%s', id, client);
const authHandoffModel = new OAuthHandoffModel(serverDB);
const result = await authHandoffModel.fetchAndConsume(id, client);
if (!result) {
handoffLog('Handoff record not found or expired - id=%s', id);
return Response.json({ error: 'Handoff record not found or expired' }, { status: 404 });
}
handoffLog('Handoff record found and consumed - id=%s', id);
return Response.json({ data: result, success: true });
} catch (error) {
console.error('Error fetching handoff record: %O', error);
return Response.json({ error: 'Internal server error' }, { status: 500 });
}
};
+4
View File
@@ -0,0 +1,4 @@
import lobeOpenApi from '@lobechat/openapi';
export const openAPIHandler = (request: Request): Response | Promise<Response> =>
lobeOpenApi.fetch(request);
+86
View File
@@ -0,0 +1,86 @@
import type {
EdgeSpeechPayload,
MicrosoftSpeechPayload,
OpenAISTTPayload,
OpenAITTSPayload,
} from '@lobehub/tts';
import { EdgeSpeechTTS, MicrosoftSpeechTTS } from '@lobehub/tts';
import { createOpenaiAudioSpeech, createOpenaiAudioTranscriptions } from '@lobehub/tts/server';
import { createBizOpenAI } from '@/app/(backend)/_deprecated/createBizOpenAI';
import { createSpeechResponse } from '@/server/utils/createSpeechResponse';
type OpenAITTSClient = Parameters<typeof createOpenaiAudioSpeech>[0]['openai'];
type OpenAISTTClient = Parameters<typeof createOpenaiAudioTranscriptions>[0]['openai'];
export const edgeTTSAPIHandler = async (request: Request): Promise<Response> => {
const payload = (await request.json()) as EdgeSpeechPayload;
return createSpeechResponse(() => EdgeSpeechTTS.createRequest({ payload }), {
logTag: 'webapi/tts/edge',
messages: {
failure: 'Failed to synthesize speech',
invalid: 'Unexpected payload from Edge speech API',
},
});
};
export const microsoftTTSAPIHandler = async (request: Request): Promise<Response> => {
const payload = (await request.json()) as MicrosoftSpeechPayload;
return createSpeechResponse(() => MicrosoftSpeechTTS.createRequest({ payload }), {
logTag: 'webapi/tts/microsoft',
messages: {
failure: 'Failed to synthesize speech',
invalid: 'Unexpected payload from Microsoft speech API',
},
});
};
export const openAITTSAPIHandler = async (request: Request): Promise<Response> => {
const payload = (await request.json()) as OpenAITTSPayload;
const openaiOrErrResponse = createBizOpenAI(request);
if (openaiOrErrResponse instanceof Response) return openaiOrErrResponse;
return createSpeechResponse(
() =>
createOpenaiAudioSpeech({
openai: openaiOrErrResponse as unknown as OpenAITTSClient,
payload,
}),
{
logTag: 'webapi/tts/openai',
messages: {
failure: 'Failed to synthesize speech',
invalid: 'Unexpected payload from OpenAI TTS',
},
},
);
};
export const openAISTTAPIHandler = async (request: Request): Promise<Response> => {
const formData = await request.formData();
const speechBlob = formData.get('speech') as Blob;
const optionsString = formData.get('options') as string;
const payload = {
options: JSON.parse(optionsString),
speech: speechBlob,
} as OpenAISTTPayload;
const openaiOrErrResponse = createBizOpenAI(request);
if (openaiOrErrResponse instanceof Response) return openaiOrErrResponse;
const response = await createOpenaiAudioTranscriptions({
openai: openaiOrErrResponse as unknown as OpenAISTTClient,
payload,
});
return new Response(JSON.stringify(response), {
headers: {
'content-type': 'application/json;charset=UTF-8',
},
});
};
+46
View File
@@ -0,0 +1,46 @@
import { TraceEventType } from '@lobechat/types';
import { TraceClient } from '@/libs/traces';
import { scheduleAfterResponse } from '@/server/utils/scheduleAfterResponse';
import { type TraceEventBasePayload, type TraceEventPayloads } from '@/types/trace';
export interface TraceAPIHandlerOptions {
scheduleAfterResponse?: (task: () => Promise<void> | void) => void;
}
export const traceAPIHandler = async (request: Request, options: TraceAPIHandlerOptions = {}) => {
type RequestData = TraceEventPayloads & TraceEventBasePayload;
const data = (await request.json()) as RequestData;
const { eventType, traceId } = data;
const traceClient = new TraceClient();
const eventClient = traceClient.createEvent(traceId);
switch (eventType) {
case TraceEventType.ModifyMessage: {
eventClient?.modifyMessage(data);
break;
}
case TraceEventType.DeleteAndRegenerateMessage: {
eventClient?.deleteAndRegenerateMessage(data);
break;
}
case TraceEventType.RegenerateMessage: {
eventClient?.regenerateMessage(data);
break;
}
case TraceEventType.CopyMessage: {
eventClient?.copyMessage(data);
break;
}
}
const schedule = options.scheduleAfterResponse ?? scheduleAfterResponse;
schedule(() => traceClient.shutdownAsync());
return new Response(undefined, { status: 201 });
};
+53
View File
@@ -0,0 +1,53 @@
import { serverDB } from '@/database/server';
import { UserService } from '@/server/services/user';
const CONTENT_TYPE_MAP: Record<string, string> = {
avif: 'image/avif',
bmp: 'image/bmp',
gif: 'image/gif',
heic: 'image/heic',
heif: 'image/heif',
ico: 'image/x-icon',
jpeg: 'image/jpeg',
jpg: 'image/jpg',
png: 'image/png',
svg: 'image/svg+xml',
tif: 'image/tiff',
tiff: 'image/tiff',
webp: 'image/webp',
};
const getContentType = (filename: string): string => {
const extension = filename.split('.').pop()?.toLowerCase() || '';
return CONTENT_TYPE_MAP[extension] || 'application/octet-stream';
};
export const userAvatarAPIHandler = async (
_request: Request,
params: { id: string; image: string },
): Promise<Response> => {
try {
const type = getContentType(params.image);
const userService = new UserService(serverDB);
const userAvatar = await userService.getUserAvatar(params.id, params.image);
if (!userAvatar) {
return new Response('Avatar not found', {
status: 404,
});
}
return new Response(userAvatar, {
headers: {
'Cache-Control': 'public, max-age=31536000, immutable',
'Content-Type': type,
},
status: 200,
});
} catch (error) {
console.error('Error fetching user avatar:', error);
return new Response('Internal server error', {
status: 500,
});
}
};
@@ -0,0 +1,23 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import honoApp from '@/server/hono';
import pkg from '../../../../package.json';
import { versionAPIHandler } from './version';
const expectVersionResponse = async (response: Response) => {
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ version: pkg.version });
};
describe('/api/version', () => {
it('versionAPIHandler returns the app version', async () => {
await expectVersionResponse(versionAPIHandler(new Request('https://example.com/api/version')));
});
it('is served by the root Hono runtime app', async () => {
const response = await honoApp.fetch(new Request('https://example.com/api/version'));
await expectVersionResponse(response);
});
});
+10
View File
@@ -0,0 +1,10 @@
import pkg from '../../../../package.json';
export interface VersionResponseData {
version: string;
}
export const versionAPIHandler = (_request: Request): Response =>
Response.json({
version: pkg.version,
} satisfies VersionResponseData);
+274
View File
@@ -0,0 +1,274 @@
import { timingSafeEqual } from 'node:crypto';
import {
buildMappedBusinessModelFields,
resolveBusinessModelMapping,
} from '@lobechat/business-model-runtime';
import { ModelRuntime } from '@lobechat/model-runtime';
import type { VideoGenerationAsset, VideoGenerationTaskMetadata } from '@lobechat/types';
import { AsyncTaskError, AsyncTaskErrorType, AsyncTaskStatus, FileSource } from '@lobechat/types';
import debug from 'debug';
import { eq } from 'drizzle-orm';
import type { RuntimeVideoGenParams } from 'model-bank';
import { chargeAfterGenerate } from '@/business/server/video-generation/chargeAfterGenerate';
import { notifyVideoCompleted } from '@/business/server/video-generation/notifyVideoCompleted';
import { AsyncTaskModel } from '@/database/models/asyncTask';
import { GenerationModel } from '@/database/models/generation';
import { generationBatches } from '@/database/schemas';
import { getServerDB } from '@/database/server';
import { VideoGenerationService } from '@/server/services/generation/video';
import { sanitizeFileName } from '@/utils/sanitizeFileName';
const log = debug('lobe-video:webhook');
const safeCompare = (a: string, b: string): boolean => {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
};
export interface VideoWebhookParams {
provider: string;
}
export const videoWebhookAPIHandler = async (request: Request, params: VideoWebhookParams) => {
const { provider } = params;
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
}
log('Received video webhook for provider: %s, body: %O', provider, body);
let asyncTaskModel: AsyncTaskModel | undefined;
let asyncTaskId: string | undefined;
let asyncTaskUserId: string | undefined;
let asyncTaskMetadata: VideoGenerationTaskMetadata | undefined;
try {
const runtime = ModelRuntime.initializeWithProvider(provider, {
apiKey: 'webhook-placeholder',
});
const result = await runtime.handleCreateVideoWebhook({ body });
if (!result) {
return Response.json(
{ error: `Provider ${provider} does not support video webhook` },
{ status: 400 },
);
}
if (result.status === 'pending') {
log('Skipping intermediate status for provider: %s', provider);
return Response.json({ success: true });
}
log('Webhook parse result: %O', result);
const db = await getServerDB();
const asyncTask = await AsyncTaskModel.findByInferenceId(db, result.inferenceId);
if (!asyncTask) {
log('AsyncTask not found for inferenceId: %s', result.inferenceId);
return Response.json(
{ error: `AsyncTask not found for inferenceId: ${result.inferenceId}` },
{ status: 404 },
);
}
const url = new URL(request.url);
const token = url.searchParams.get('token');
const metadata = asyncTask.metadata as VideoGenerationTaskMetadata | undefined;
const expectedToken = metadata?.webhookToken;
if (!expectedToken || !token || !safeCompare(token, expectedToken)) {
log('Webhook token verification failed for asyncTask: %s', asyncTask.id);
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
log('Webhook token verified for asyncTask: %s', asyncTask.id);
asyncTaskId = asyncTask.id;
asyncTaskUserId = asyncTask.userId;
asyncTaskMetadata = metadata;
log(
'Found asyncTask: %s, userId: %s, status: %s',
asyncTask.id,
asyncTask.userId,
asyncTask.status,
);
if (
asyncTask.status === AsyncTaskStatus.Success ||
asyncTask.status === AsyncTaskStatus.Error
) {
log('AsyncTask %s already in terminal state: %s, skipping', asyncTask.id, asyncTask.status);
return Response.json({ success: true });
}
const generationModel = new GenerationModel(db, asyncTask.userId);
const generation = await generationModel.findByAsyncTaskId(asyncTask.id);
if (!generation) {
log('Generation not found for asyncTaskId: %s', asyncTask.id);
return Response.json(
{ error: `Generation not found for asyncTaskId: ${asyncTask.id}` },
{ status: 404 },
);
}
log('Found generation: %s', generation.id);
asyncTaskModel = new AsyncTaskModel(db, asyncTask.userId);
const batch = await db.query.generationBatches.findFirst({
where: eq(generationBatches.id, generation.generationBatchId!),
});
const requestedModel = batch?.model ?? '';
const { resolvedModelId } = requestedModel
? await resolveBusinessModelMapping(provider, requestedModel)
: { resolvedModelId: '' };
const mappedModelFields = buildMappedBusinessModelFields({
provider,
requestedModelId: resolvedModelId === requestedModel ? undefined : requestedModel,
resolvedModelId,
});
if (result.status === 'error') {
log('Video generation failed: %s', result.error);
await asyncTaskModel.update(asyncTask.id, {
error: new AsyncTaskError(AsyncTaskErrorType.ServerError, result.error),
status: AsyncTaskStatus.Error,
});
try {
await chargeAfterGenerate({
isError: true,
metadata: {
asyncTaskId: asyncTask.id,
generationBatchId: generation.generationBatchId!,
topicId: batch?.generationTopicId,
...mappedModelFields,
},
model: resolvedModelId,
prechargeResult: metadata?.precharge as any,
provider,
userId: asyncTask.userId,
});
} catch (refundError) {
console.error('[video-webhook] Failed to refund precharge on error:', refundError);
}
return Response.json({ success: true });
}
const videoService = new VideoGenerationService(db, asyncTask.userId);
const processResult = await videoService.processVideoForGeneration(result.videoUrl);
const asset: VideoGenerationAsset = {
coverUrl: processResult.coverKey,
duration: processResult.duration,
height: processResult.height,
originalUrl: result.videoUrl,
thumbnailUrl: processResult.thumbnailKey,
type: 'video',
url: processResult.videoKey,
width: processResult.width,
};
await generationModel.createAssetAndFile(
generation.id,
asset,
{
fileHash: processResult.fileHash,
fileType: processResult.mimeType,
name: `${sanitizeFileName(batch?.prompt ?? '', generation.id)}.mp4`,
size: processResult.fileSize,
url: processResult.videoKey,
},
FileSource.VideoGeneration,
);
const duration = Date.now() - asyncTask.createdAt.getTime();
await asyncTaskModel.update(asyncTask.id, {
duration,
status: AsyncTaskStatus.Success,
});
try {
await notifyVideoCompleted({
generationBatchId: generation.generationBatchId!,
model: requestedModel,
prompt: batch?.prompt ?? '',
topicId: batch?.generationTopicId,
userId: asyncTask.userId,
});
} catch (err) {
console.error('[video-webhook] notification failed:', err);
}
try {
await chargeAfterGenerate({
computePriceParams: {
generateAudio: (batch?.config as RuntimeVideoGenParams)?.generateAudio,
resolution: (batch?.config as RuntimeVideoGenParams)?.resolution,
},
latency: duration,
metadata: {
asyncTaskId: asyncTask.id,
generationBatchId: generation.generationBatchId!,
topicId: batch?.generationTopicId,
...mappedModelFields,
},
model: resolvedModelId,
prechargeResult: metadata?.precharge as any,
provider,
usage: result.usage,
userId: asyncTask.userId,
});
} catch (chargeError) {
console.error('[video-webhook] Failed to charge after generate:', chargeError);
}
log('Video webhook processing completed successfully for generation: %s', generation.id);
return Response.json({ success: true });
} catch (error) {
console.error('[video-webhook] Processing failed:', error);
if (asyncTaskModel && asyncTaskId) {
try {
await asyncTaskModel.update(asyncTaskId, {
error: new AsyncTaskError(AsyncTaskErrorType.ServerError, (error as Error).message),
status: AsyncTaskStatus.Error,
});
} catch (updateError) {
console.error('[video-webhook] Failed to update asyncTask status:', updateError);
}
}
if (asyncTaskUserId && asyncTaskMetadata?.precharge) {
try {
await chargeAfterGenerate({
isError: true,
metadata: { asyncTaskId: asyncTaskId ?? '', generationBatchId: '', modelId: '' },
model: '',
prechargeResult: asyncTaskMetadata.precharge as any,
provider,
userId: asyncTaskUserId,
});
} catch (refundError) {
console.error('[video-webhook] Failed to refund precharge on failure:', refundError);
}
}
return Response.json({ error: (error as Error).message }, { status: 500 });
}
};
+174
View File
@@ -0,0 +1,174 @@
import { createHmac } from 'node:crypto';
import { serverDB } from '@/database/server';
import { authEnv } from '@/envs/auth';
import { WebhookUserService } from '@/server/services/webhookUser';
export interface CasdoorUserEntity {
avatar?: string;
displayName: string;
email?: string;
id: string;
}
interface CasdoorWebhookPayload {
action: string;
object: CasdoorUserEntity;
}
export interface LogtoUserEntity {
applicationId?: string;
avatar?: string;
createdAt?: string;
customData?: object;
id: string;
identities?: object;
isSuspended?: boolean;
lastSignInAt?: string;
name?: string;
primaryEmail?: string;
primaryPhone?: string;
username?: string;
}
interface LogtoWebhookPayload {
data: LogtoUserEntity;
event: string;
}
const validateCasdoorRequest = async (request: Request, secret?: string) => {
const payloadString = await request.text();
const casdoorSecret = request.headers.get('casdoor-secret');
try {
if (casdoorSecret === secret) {
return JSON.parse(payloadString, (key, value) =>
key === 'object' && typeof value === 'string' ? JSON.parse(value) : value,
) as CasdoorWebhookPayload;
}
console.warn(
'[Casdoor]: secret verify failed, please check your secret in `CASDOOR_WEBHOOK_SECRET`',
);
} catch (error) {
if (!authEnv.CASDOOR_WEBHOOK_SECRET) {
throw new Error('`CASDOOR_WEBHOOK_SECRET` environment variable is missing.', {
cause: error,
});
}
console.error('[Casdoor]: incoming webhook failed in verification.\n', error, payloadString);
}
};
const validateLogtoRequest = async (request: Request, signingKey?: string) => {
const payloadString = await request.text();
const logtoHeaderSignature = request.headers.get('logto-signature-sha-256');
try {
const hmac = createHmac('sha256', signingKey ?? '');
hmac.update(payloadString);
const signature = hmac.digest('hex');
if (signature === logtoHeaderSignature) {
return JSON.parse(payloadString) as LogtoWebhookPayload;
}
console.warn(
'[logto]: signature verify failed, please check your logto signature in `LOGTO_WEBHOOK_SIGNING_KEY`',
);
} catch (error) {
if (!authEnv.LOGTO_WEBHOOK_SIGNING_KEY) {
throw new Error('`LOGTO_WEBHOOK_SIGNING_KEY` environment variable is missing.', {
cause: error,
});
}
console.error('[logto]: incoming webhook failed in verification.\n', error);
}
};
export const casdoorWebhookAPIHandler = async (request: Request): Promise<Response> => {
const payload = await validateCasdoorRequest(request, authEnv.CASDOOR_WEBHOOK_SECRET);
if (!payload) {
return Response.json(
{ error: 'webhook verification failed or payload was malformed' },
{ status: 400 },
);
}
const { action, object } = payload;
const webhookUserService = new WebhookUserService(serverDB);
switch (action) {
case 'update-user': {
return webhookUserService.safeUpdateUser(
{
accountId: object.id,
providerId: 'casdoor',
},
{
avatar: object?.avatar,
email: object?.email,
fullName: object.displayName,
},
);
}
default: {
console.warn(
`${request.url} received event type "${action}", but no handler is defined for this type`,
);
return Response.json({ error: `unrecognised payload type: ${action}` }, { status: 400 });
}
}
};
export const logtoWebhookAPIHandler = async (request: Request): Promise<Response> => {
const payload = await validateLogtoRequest(request, authEnv.LOGTO_WEBHOOK_SIGNING_KEY);
if (!payload) {
return Response.json(
{ error: 'webhook verification failed or payload was malformed' },
{ status: 400 },
);
}
const { event, data } = payload;
console.info(`logto webhook payload: ${{ data, event }}`);
const webhookUserService = new WebhookUserService(serverDB);
switch (event) {
case 'User.Data.Updated': {
return webhookUserService.safeUpdateUser(
{
accountId: data.id,
providerId: 'logto',
},
{
avatar: data?.avatar,
email: data?.primaryEmail,
fullName: data?.name,
},
);
}
case 'User.SuspensionStatus.Updated': {
if (data.isSuspended) {
return webhookUserService.safeSignOutUser({
accountId: data.id,
providerId: 'logto',
});
}
return Response.json({ message: 'user reactivated', success: true }, { status: 200 });
}
default: {
console.warn(
`${request.url} received event type "${event}", but no handler is defined for this type`,
);
return Response.json({ error: `unrecognised payload type: ${event}` }, { status: 400 });
}
}
};
+145
View File
@@ -0,0 +1,145 @@
import type { Context } from 'hono';
import { Hono } from 'hono';
const app = new Hono();
const fetchWith = async (
c: Context,
importer: () => Promise<{
default: { fetch: (request: Request) => Promise<Response> | Response };
}>,
) => (await importer()).default.fetch(c.req.raw);
app.get('/api/version', async (c) =>
(await import('@/server/api-runtime/version')).versionAPIHandler(c.req.raw),
);
app.all('/trpc/*', (c) => fetchWith(c, () => import('@/server/trpc-hono')));
app.get('/api/agent/stream', async (c) =>
(await import('@/server/api-runtime/agentStream')).agentStreamAPIHandler(c.req.raw),
);
app.all('/api/agent', (c) => fetchWith(c, () => import('@/server/agent-hono')));
app.all('/api/agent/*', (c) => fetchWith(c, () => import('@/server/agent-hono')));
app.post('/api/workflows/agent-eval-run/on-thread-complete', async (c) =>
(
await import('@/server/api-runtime/agentEvalRunWorkflow')
).agentEvalRunOnThreadCompleteAPIHandler(c.req.raw),
);
app.post('/api/workflows/agent-eval-run/on-trajectory-complete', async (c) =>
(
await import('@/server/api-runtime/agentEvalRunWorkflow')
).agentEvalRunOnTrajectoryCompleteAPIHandler(c.req.raw),
);
app.all('/api/workflows', (c) => fetchWith(c, () => import('@/server/workflows-hono')));
app.all('/api/workflows/*', (c) => fetchWith(c, () => import('@/server/workflows-hono')));
app.all('/api/*', (c) => fetchWith(c, () => import('@/server/api-hono')));
app.get('/f/:id', async (c) =>
(await import('@/server/api-runtime/fileProxy')).fileProxyAPIHandler(c.req.raw, {
id: c.req.param('id'),
}),
);
app.get('/webapi/user/avatar/:id/:image', async (c) =>
(await import('@/server/api-runtime/userAvatar')).userAvatarAPIHandler(c.req.raw, {
id: c.req.param('id'),
image: c.req.param('image'),
}),
);
app.on(['GET', 'POST'], '/market/agent', async (c) =>
(await import('@/server/api-runtime/market')).marketAgentAPIHandler(c.req.raw, {
segments: [],
}),
);
app.on(['GET', 'POST'], '/market/agent/*', async (c) =>
(await import('@/server/api-runtime/market')).marketAgentAPIHandler(c.req.raw, {
segments: c.req.path
.replace(/^\/market\/agent\/?/, '')
.split('/')
.filter(Boolean),
}),
);
app.on(['GET', 'POST'], '/market/oidc', async (c) =>
(await import('@/server/api-runtime/market')).marketOIDCAPIHandler(c.req.raw, {
segments: [],
}),
);
app.on(['GET', 'POST'], '/market/oidc/*', async (c) =>
(await import('@/server/api-runtime/market')).marketOIDCAPIHandler(c.req.raw, {
segments: c.req.path
.replace(/^\/market\/oidc\/?/, '')
.split('/')
.filter(Boolean),
}),
);
app.on(['GET', 'POST'], '/market/social', async (c) =>
(await import('@/server/api-runtime/market')).marketSocialAPIHandler(c.req.raw, {
segments: [],
}),
);
app.on(['GET', 'POST'], '/market/social/*', async (c) =>
(await import('@/server/api-runtime/market')).marketSocialAPIHandler(c.req.raw, {
segments: c.req.path
.replace(/^\/market\/social\/?/, '')
.split('/')
.filter(Boolean),
}),
);
app.get('/market/user/:username', async (c) =>
(await import('@/server/api-runtime/market')).marketUserProfileAPIHandler(c.req.raw, {
username: c.req.param('username'),
}),
);
app.put('/market/user/me', async (c) =>
(await import('@/server/api-runtime/market')).marketUserMeAPIHandler(c.req.raw),
);
app.get('/oidc/handoff', async (c) =>
(await import('@/server/api-runtime/oidc')).oidcHandoffAPIHandler(c.req.raw),
);
app.get('/oidc/callback/desktop', async (c) =>
(await import('@/server/api-runtime/oidc')).oidcCallbackDesktopAPIHandler(c.req.raw),
);
app.post('/oidc/clear-session', async (c) =>
(await import('@/server/api-runtime/oidc')).oidcClearSessionAPIHandler(c.req.raw),
);
app.post('/oidc/consent', async (c) =>
(await import('@/server/api-runtime/oidc')).oidcConsentAPIHandler(c.req.raw),
);
app.all('/oidc/*', async (c) =>
(await import('@/server/api-runtime/oidc')).oidcProviderAPIHandler(c.req.raw),
);
app.get('/webapi/models/:provider', async (c) =>
(await import('@/server/api-runtime/models')).modelsAPIHandler(c.req.raw, {
provider: c.req.param('provider'),
}),
);
app.post('/webapi/models/:provider/pull', async (c) =>
(await import('@/server/api-runtime/models')).pullModelsAPIHandler(c.req.raw, {
provider: c.req.param('provider'),
}),
);
app.post('/webapi/chat/:provider', async (c) =>
(await import('@/server/api-runtime/chat')).chatAPIHandler(c.req.raw, {
provider: c.req.param('provider'),
}),
);
app.post('/webapi/create-image/comfyui', async (c) =>
(await import('@/server/api-runtime/createImage')).comfyUICreateImageAPIHandler(c.req.raw),
);
app.post('/webapi/tts/edge', async (c) =>
(await import('@/server/api-runtime/speech')).edgeTTSAPIHandler(c.req.raw),
);
app.post('/webapi/tts/microsoft', async (c) =>
(await import('@/server/api-runtime/speech')).microsoftTTSAPIHandler(c.req.raw),
);
app.post('/webapi/tts/openai', async (c) =>
(await import('@/server/api-runtime/speech')).openAITTSAPIHandler(c.req.raw),
);
app.post('/webapi/stt/openai', async (c) =>
(await import('@/server/api-runtime/speech')).openAISTTAPIHandler(c.req.raw),
);
app.post('/webapi/trace', async (c) =>
(await import('@/server/api-runtime/trace')).traceAPIHandler(c.req.raw),
);
export default app;
+137
View File
@@ -0,0 +1,137 @@
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
import { createServer } from 'node:http';
import { Readable } from 'node:stream';
import honoModule from './index';
interface HonoFetchApp {
fetch: (request: Request) => Promise<Response> | Response;
}
interface HonoModule {
default?: unknown;
}
type HonoStandaloneGlobal = typeof globalThis & {
__lobeHonoStandaloneServer?: Server;
};
const DEFAULT_HOST = 'localhost';
const DEFAULT_PORT = 3011;
const isHonoFetchApp = (value: unknown): value is HonoFetchApp =>
typeof value === 'object' &&
value !== null &&
'fetch' in value &&
typeof value.fetch === 'function';
const resolveApp = (): HonoFetchApp => {
if (isHonoFetchApp(honoModule)) return honoModule;
const defaultExport = (honoModule as HonoModule).default;
if (isHonoFetchApp(defaultExport)) return defaultExport;
throw new TypeError('Hono standalone entry did not resolve to a fetch-compatible app');
};
const app = resolveApp();
const resolvePort = () => {
const parsed = Number.parseInt(process.env.HONO_PORT ?? process.env.PORT ?? '', 10);
return Number.isNaN(parsed) ? DEFAULT_PORT : parsed;
};
const createRequest = (request: IncomingMessage) => {
const headers = new Headers();
for (const [key, value] of Object.entries(request.headers)) {
if (Array.isArray(value)) {
for (const item of value) headers.append(key, item);
} else if (value !== undefined) {
headers.set(key, value);
}
}
const protocol = headers.get('x-forwarded-proto') || 'http';
const host = headers.get('host') || `${DEFAULT_HOST}:${resolvePort()}`;
const url = new URL(request.url || '/', `${protocol}://${host}`);
const init: RequestInit & { duplex?: 'half' } = {
headers,
method: request.method,
};
if (request.method !== 'GET' && request.method !== 'HEAD') {
init.body = Readable.toWeb(request) as ReadableStream<Uint8Array>;
init.duplex = 'half';
}
return new Request(url, init);
};
const writeResponse = async (response: Response, outgoing: ServerResponse) => {
outgoing.statusCode = response.status;
outgoing.statusMessage = response.statusText;
const setCookie = (
response.headers as Headers & {
getSetCookie?: () => string[];
}
).getSetCookie?.();
response.headers.forEach((value, key) => {
if (key.toLowerCase() === 'set-cookie' && setCookie?.length) return;
outgoing.setHeader(key, value);
});
if (setCookie?.length) outgoing.setHeader('set-cookie', setCookie);
if (!response.body) {
outgoing.end();
return;
}
Readable.fromWeb(response.body).pipe(outgoing);
};
const host = process.env.HONO_HOST || DEFAULT_HOST;
const port = resolvePort();
process.title = `lobe-dev-hono-${port}`;
const server = createServer((request, response) => {
void (async () => {
try {
await writeResponse(await app.fetch(createRequest(request)), response);
} catch (error) {
console.error('Hono standalone request failed:', error);
response.statusCode = 500;
response.end('Internal Server Error');
}
})();
});
const closePreviousServer = (previousServer: Server | undefined) =>
new Promise<void>((resolve) => {
if (!previousServer?.listening) {
resolve();
return;
}
previousServer.close(() => resolve());
});
const startServer = async () => {
const standaloneGlobal = globalThis as HonoStandaloneGlobal;
await closePreviousServer(standaloneGlobal.__lobeHonoStandaloneServer);
standaloneGlobal.__lobeHonoStandaloneServer = server;
server.listen(port, host, () => {
console.info(`Hono runtime ready at http://${host}:${port}`);
});
};
void startServer().catch((error) => {
console.error('Failed to start Hono runtime:', error);
process.exitCode = 1;
});
@@ -2,9 +2,9 @@ import { INBOX_SESSION_ID, LOBE_CHAT_OBSERVATION_ID, LOBE_CHAT_TRACE_ID } from '
import { type ChatStreamCallbacks, type ChatStreamPayload } from '@lobechat/model-runtime';
import { type TracePayload } from '@lobechat/types';
import { TraceTagMap } from '@lobechat/types';
import { after } from 'next/server';
import { TraceClient } from '@/libs/traces';
import { scheduleAfterResponse } from '@/server/utils/scheduleAfterResponse';
export interface AgentChatOptions {
enableTrace?: boolean;
@@ -86,13 +86,13 @@ export const createTraceOptions = (
},
onFinal: () => {
after(async () => {
scheduleAfterResponse(async () => {
try {
await traceClient.shutdownAsync();
} catch (e) {
console.error('TraceClient shutdown error:', e);
}
});
}, 'model-runtime:trace');
},
onStart: () => {
@@ -15,15 +15,7 @@ import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { userRouter } from '../user';
const mockAfterTasks = vi.hoisted((): Promise<void>[] => []);
// Mock modules
vi.mock('next/server', () => ({
after: (callback: () => Promise<void> | void) => {
mockAfterTasks.push(Promise.resolve(callback()));
},
}));
vi.mock('@/business/server/user', () => ({
getReferralStatus: vi.fn(),
getSubscriptionPlan: vi.fn(),
@@ -47,12 +39,14 @@ describe('userRouter', () => {
userId: mockUserId,
};
// scheduleAfterResponse falls back to setTimeout(0) outside the Next runtime;
// drain the timer queue and let the awaited task chain settle.
const flushAfterTasks = async () => {
await Promise.all(mockAfterTasks.splice(0));
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
};
beforeEach(() => {
mockAfterTasks.length = 0;
vi.clearAllMocks();
vi.mocked(getReferralStatus).mockResolvedValue(undefined);
vi.mocked(getSubscriptionPlan).mockResolvedValue(Plans.Free);
@@ -7,7 +7,6 @@ import { AsyncTaskStatus } from '@/types/asyncTask';
// ---- hoisted mocks (available inside vi.mock factories) ----
const {
mockAfter,
mockCreateVideo,
mockFindUserById,
mockIsLobeHubModelAvailable,
@@ -19,13 +18,11 @@ const {
const mockTransaction = vi.fn();
const mockServerDB = { transaction: mockTransaction };
const mockCreateVideo = vi.fn();
const mockAfter = vi.fn((cb: () => void) => cb());
const mockFindUserById = vi.fn();
const mockIsLobeHubModelAvailable = vi.fn();
const mockProcessBackgroundVideoPolling = vi.fn().mockResolvedValue(undefined);
const mockResolveBusinessModelMapping = vi.fn();
return {
mockAfter,
mockCreateVideo,
mockFindUserById,
mockIsLobeHubModelAvailable,
@@ -78,7 +75,6 @@ vi.mock('@lobechat/business-model-bank/model-config', () => ({
vi.mock('@/business/server/video-generation/getVideoFreeQuota', () => ({
getVideoFreeQuota: vi.fn().mockResolvedValue({ remaining: 10 }),
}));
vi.mock('next/server', () => ({ after: (cb: () => void) => mockAfter(cb) }));
vi.mock('@/server/services/generation/videoBackgroundPolling', () => ({
processBackgroundVideoPolling: mockProcessBackgroundVideoPolling,
}));
@@ -96,6 +92,8 @@ const defaultInput = {
provider: 'volcengine',
};
const flushAfterResponseTasks = () => new Promise((resolve) => setTimeout(resolve, 0));
const txResult = {
asyncTaskCreatedAt: new Date('2026-01-01'),
asyncTaskId: 'async-1',
@@ -178,7 +176,8 @@ describe('videoRouter', () => {
status: AsyncTaskStatus.Processing,
});
// Webhook: should NOT trigger background polling
expect(mockAfter).not.toHaveBeenCalled();
await flushAfterResponseTasks();
expect(mockProcessBackgroundVideoPolling).not.toHaveBeenCalled();
});
it('should validate mapped model id before rejecting deprecated lobehub video models', async () => {
@@ -246,8 +245,7 @@ describe('videoRouter', () => {
inferenceId: 'inf-2',
status: AsyncTaskStatus.Processing,
});
// Polling: should trigger background polling via after()
expect(mockAfter).toHaveBeenCalled();
await flushAfterResponseTasks();
expect(mockProcessBackgroundVideoPolling).toHaveBeenCalled();
});
@@ -266,8 +264,7 @@ describe('videoRouter', () => {
inferenceId: 'inf-3',
status: AsyncTaskStatus.Processing,
});
// No special videoUrl branch — falls through to polling
expect(mockAfter).toHaveBeenCalled();
await flushAfterResponseTasks();
expect(mockProcessBackgroundVideoPolling).toHaveBeenCalled();
});
@@ -278,8 +275,7 @@ describe('videoRouter', () => {
const caller = videoRouter.createCaller(mockCtx);
await caller.createVideo(defaultInput);
// useWebhook=false means not webhook, should fall to polling
expect(mockAfter).toHaveBeenCalled();
await flushAfterResponseTasks();
expect(mockProcessBackgroundVideoPolling).toHaveBeenCalled();
});
});
+2 -2
View File
@@ -1,4 +1,3 @@
import { after } from 'next/server';
import { z } from 'zod';
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
@@ -9,6 +8,7 @@ import { HomeRepository } from '@/database/repositories/home';
import { router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { type HomeBriefData, HomeService } from '@/server/services/home';
import { scheduleAfterResponse } from '@/server/utils/scheduleAfterResponse';
const homeProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
@@ -42,7 +42,7 @@ export const homeRouter = router({
};
// Use Next.js after() for non-blocking execution
after(runMigration);
scheduleAfterResponse(runMigration, 'AgentMigration');
return result;
}),
+3 -3
View File
@@ -5,7 +5,6 @@ import {
} from '@lobechat/types';
import { cleanObject } from '@lobechat/utils';
import { eq, inArray } from 'drizzle-orm';
import { after } from 'next/server';
import { z } from 'zod';
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
@@ -19,6 +18,7 @@ import { TopicImporterRepo } from '@/database/repositories/topicImporter';
import { agents, chatGroups, chatGroupsAgents } from '@/database/schemas';
import { router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { scheduleAfterResponse } from '@/server/utils/scheduleAfterResponse';
import { type BatchTaskResult } from '@/types/service';
import {
@@ -349,7 +349,7 @@ export const topicRouter = router({
};
// Use Next.js after() for non-blocking execution
after(runMigration);
scheduleAfterResponse(runMigration, 'AgentMigration:list');
return { items: result.items, total: result.total };
}),
@@ -505,7 +505,7 @@ export const topicRouter = router({
};
// Use Next.js after() for non-blocking execution
after(runMigration);
scheduleAfterResponse(runMigration, 'AgentMigration:recentTopics');
// Assemble final result
return recentTopics.map((topic) => {
+3 -3
View File
@@ -19,7 +19,6 @@ import {
UserSettingsSchema,
} from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import { after } from 'next/server';
import { v4 as uuidv4 } from 'uuid';
import { z } from 'zod';
@@ -39,6 +38,7 @@ import { FileS3 } from '@/server/modules/S3';
import { AgentDocumentsService } from '@/server/services/agentDocuments';
import { FileService } from '@/server/services/file';
import { OnboardingService } from '@/server/services/onboarding';
import { scheduleAfterResponse } from '@/server/utils/scheduleAfterResponse';
const usernameSchema = z
.string()
@@ -106,7 +106,7 @@ export const userRouter = router({
getUserState: userProcedure.query(async ({ ctx }): Promise<UserInitializationState> => {
try {
after(async () => {
scheduleAfterResponse(async () => {
try {
const currentTime = new Date();
const transition = await ctx.userModel.advanceLastActiveAt(currentTime);
@@ -126,7 +126,7 @@ export const userRouter = router({
} catch (err) {
console.error('update lastActiveAt failed, error:', err);
}
});
}, 'user:getUserState');
} catch {
// `after` may fail outside request scope (e.g., in tests), ignore silently
}
@@ -10,7 +10,6 @@ import { ChatErrorType, RequestTrigger } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import { and, eq } from 'drizzle-orm';
import { after } from 'next/server';
import { z } from 'zod';
import { getProviderContentPolicyErrorMessage } from '@/business/server/getProviderContentPolicyErrorMessage';
@@ -35,6 +34,7 @@ import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { FileService } from '@/server/services/file';
import { processBackgroundVideoPolling } from '@/server/services/generation/videoBackgroundPolling';
import { scheduleAfterResponse } from '@/server/utils/scheduleAfterResponse';
import { AsyncTaskStatus, AsyncTaskType } from '@/types/asyncTask';
import { createVideoTaskSubmitError } from './error';
@@ -284,7 +284,7 @@ export const videoRouter = router({
status: AsyncTaskStatus.Processing,
});
after(async () => {
scheduleAfterResponse(async () => {
log('After() hook executing background video polling for task: %s', asyncTaskId);
try {
@@ -308,7 +308,7 @@ export const videoRouter = router({
} catch (error) {
console.error('[video] Background polling failed:', error);
}
});
}, 'video:background-polling');
log('After() hook registered for background video polling: %s', asyncTaskId);
}
@@ -10,11 +10,6 @@ import {
type ScheduleToolCallReportParams,
} from './scheduleToolCallReport';
// Mock Next.js after() function
vi.mock('next/server', () => ({
after: vi.fn((callback) => callback()),
}));
// Mock DiscoverService
vi.mock('@/server/services/discover', () => ({
DiscoverService: vi.fn().mockImplementation(() => ({
@@ -478,12 +473,20 @@ describe('scheduleToolCallReport', () => {
consoleErrorSpy.mockRestore();
});
it('should use Next.js after() to schedule reporting', async () => {
const { after } = await import('next/server');
it('should schedule reporting after the response path', async () => {
const mockReportCall = vi.fn().mockResolvedValue(undefined);
(DiscoverService as any).mockImplementation(() => ({
reportCall: mockReportCall,
}));
scheduleToolCallReport(baseParams);
expect(after).toHaveBeenCalledWith(expect.any(Function));
expect(mockReportCall).not.toHaveBeenCalled();
await vi.runAllTimersAsync();
expect(mockReportCall).toHaveBeenCalledWith(
expect.objectContaining({ identifier: 'test-plugin' }),
);
});
it('should create DiscoverService with marketAccessToken', async () => {
@@ -1,8 +1,8 @@
import { CURRENT_VERSION } from '@lobechat/const';
import { type CallReportRequest } from '@lobehub/market-types';
import { after } from 'next/server';
import { DiscoverService } from '@/server/services/discover';
import { scheduleAfterResponse } from '@/server/utils/scheduleAfterResponse';
/**
* Calculate byte size of object
@@ -78,7 +78,7 @@ export function scheduleToolCallReport(params: ScheduleToolCallReportParams): vo
if (!telemetryEnabled || !marketAccessToken) return;
// Use Next.js after() to report after response is sent
after(async () => {
scheduleAfterResponse(async () => {
try {
const callDurationMs = Date.now() - startTime;
const requestSizeBytes = calculateObjectSizeBytes(requestPayload);
@@ -109,5 +109,5 @@ export function scheduleToolCallReport(params: ScheduleToolCallReportParams): vo
} catch (reportError) {
console.error('Failed to report tool call: %O', reportError);
}
});
}, 'tools:scheduleToolCallReport');
}
+2 -3
View File
@@ -1,7 +1,6 @@
import { type LobeToolManifest } from '@lobechat/context-engine';
import { MarketSDK, type OrgRef, orgRefToPathSegment } from '@lobehub/market-sdk';
import debug from 'debug';
import { type NextRequest } from 'next/server';
import { type TrustedClientUserInfo } from '@/libs/trusted-client';
import { generateTrustedClientToken, getTrustedClientTokenForSession } from '@/libs/trusted-client';
@@ -15,7 +14,7 @@ const MARKET_BASE_URL = process.env.MARKET_BASE_URL || 'https://market.lobehub.c
/**
* Extract access token from Authorization header
*/
export function extractAccessToken(req: NextRequest): string | undefined {
export function extractAccessToken(req: Request): string | undefined {
const authHeader = req.headers.get('authorization');
if (authHeader?.startsWith('Bearer ')) {
return authHeader.slice(7);
@@ -122,7 +121,7 @@ export class MarketService {
* Create MarketService from Next.js request (server-side only)
* Extracts accessToken from Authorization header and trustedClientToken from session
*/
static async createFromRequest(req: NextRequest): Promise<MarketService> {
static async createFromRequest(req: Request): Promise<MarketService> {
const accessToken = extractAccessToken(req);
const trustedClientToken = await getTrustedClientTokenForSession();
@@ -1,6 +1,5 @@
import { type LobeChatDatabase } from '@lobechat/database';
import { and, eq } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { UserModel } from '@/database/models/user';
import { type UserItem } from '@/database/schemas';
@@ -76,7 +75,7 @@ export class WebhookUserService {
);
}
return NextResponse.json({ message: 'user updated', success: true }, { status: 200 });
return Response.json({ message: 'user updated', success: true }, { status: 200 });
};
/**
@@ -101,6 +100,6 @@ export class WebhookUserService {
);
}
return NextResponse.json({ message: 'user signed out', success: true }, { status: 200 });
return Response.json({ message: 'user signed out', success: true }, { status: 200 });
};
}
+19
View File
@@ -0,0 +1,19 @@
import { Hono } from 'hono';
import { asyncTRPCHandler } from '@/server/trpc-runtime/async';
import { lambdaTRPCHandler } from '@/server/trpc-runtime/lambda';
import { mobileTRPCHandler } from '@/server/trpc-runtime/mobile';
import { toolsTRPCHandler } from '@/server/trpc-runtime/tools';
/**
* Hono app for `/trpc/*` endpoints. Mounts the four tRPC route groups so the
* standalone Hono runtime can serve tRPC without Next.js.
*/
const app = new Hono().basePath('/trpc');
app.all('/async/*', (c) => asyncTRPCHandler(c.req.raw));
app.all('/lambda/*', (c) => lambdaTRPCHandler(c.req.raw));
app.all('/mobile/*', (c) => mobileTRPCHandler(c.req.raw));
app.all('/tools/*', (c) => toolsTRPCHandler(c.req.raw));
export default app;
+23
View File
@@ -0,0 +1,23 @@
import { createAsyncRouteContext } from '@/libs/trpc/async/context';
import { createResponseMeta } from '@/libs/trpc/utils/responseMeta';
import { asyncRouter } from '@/server/routers/async';
import { createTRPCRouteHandler } from './createTRPCRouteHandler';
export const asyncTRPCHandler = createTRPCRouteHandler({
// Avoid interference between requests
// https://github.com/lobehub/lobe-chat/discussions/7442#discussioncomment-13658563
allowBatching: false,
/**
* @link https://trpc.io/docs/v11/context
*/
createContext: createAsyncRouteContext,
endpoint: '/trpc/async',
onError: ({ error, path, type }) => {
console.info(`Error in tRPC handler (async) on path: ${path}, type: ${type}`);
console.error(error);
},
responseMeta: createResponseMeta,
router: asyncRouter,
});
@@ -0,0 +1,24 @@
import type { AnyRouter } from '@trpc/server';
import type { FetchCreateContextFn, FetchHandlerRequestOptions } from '@trpc/server/adapters/fetch';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { prepareRequestForTRPC } from '@/libs/trpc/utils/request-adapter';
type CreateTRPCRouteHandlerOptions<TRouter extends AnyRouter> = Omit<
FetchHandlerRequestOptions<TRouter>,
'createContext' | 'req'
> & {
createContext: (request: Request) => ReturnType<FetchCreateContextFn<TRouter>>;
};
export const createTRPCRouteHandler =
<TRouter extends AnyRouter>({
createContext,
...options
}: CreateTRPCRouteHandlerOptions<TRouter>) =>
(request: Request): Promise<Response> =>
fetchRequestHandler({
...options,
createContext: () => createContext(request),
req: prepareRequestForTRPC(request),
});
+23
View File
@@ -0,0 +1,23 @@
import { createLambdaContext } from '@/libs/trpc/lambda/context';
import { createResponseMeta } from '@/libs/trpc/utils/responseMeta';
import { lambdaRouter } from '@/server/routers/lambda';
import { createTRPCRouteHandler } from './createTRPCRouteHandler';
export const lambdaTRPCHandler = createTRPCRouteHandler({
/**
* @link https://trpc.io/docs/v11/context
*/
createContext: createLambdaContext,
endpoint: '/trpc/lambda',
onError: ({ error, path, type }) => {
// Filter out the error of UNAUTHORIZED, because this is normal behavior
// And it has been displayed at the front end to let the user login
if (error.code === 'UNAUTHORIZED') return;
console.info(`Error in tRPC handler (lambda) on path: ${path}, type: ${type}`);
console.error(error);
},
responseMeta: createResponseMeta,
router: lambdaRouter,
});
+19
View File
@@ -0,0 +1,19 @@
import { createLambdaContext } from '@/libs/trpc/lambda/context';
import { createResponseMeta } from '@/libs/trpc/utils/responseMeta';
import { mobileRouter } from '@/server/routers/mobile';
import { createTRPCRouteHandler } from './createTRPCRouteHandler';
export const mobileTRPCHandler = createTRPCRouteHandler({
/**
* @link https://trpc.io/docs/v11/context
*/
createContext: createLambdaContext,
endpoint: '/trpc/mobile',
onError: ({ error, path, type }) => {
console.info(`Error in tRPC handler (mobile) on path: ${path}, type: ${type}`);
console.error(error);
},
responseMeta: createResponseMeta,
router: mobileRouter,
});
+19
View File
@@ -0,0 +1,19 @@
import { createLambdaContext } from '@/libs/trpc/lambda/context';
import { createResponseMeta } from '@/libs/trpc/utils/responseMeta';
import { toolsRouter } from '@/server/routers/tools';
import { createTRPCRouteHandler } from './createTRPCRouteHandler';
export const toolsTRPCHandler = createTRPCRouteHandler({
/**
* @link https://trpc.io/docs/v11/context
*/
createContext: createLambdaContext,
endpoint: '/trpc/tools',
onError: ({ error, path, type }) => {
console.error(`Error in tRPC handler (tools) on path: ${path}, type: ${type}`);
console.error(error);
},
responseMeta: createResponseMeta,
router: toolsRouter,
});
@@ -0,0 +1,37 @@
export type AfterResponseTask = () => Promise<void> | void;
const runTask = async (task: AfterResponseTask, label: string) => {
try {
await task();
} catch (error) {
console.error(`[${label}] post-response task failed`, error);
}
};
const runFallbackAfterResponse = (task: AfterResponseTask, label: string) => {
setTimeout(() => {
void runTask(task, label);
}, 0);
};
export const scheduleAfterResponse = (task: AfterResponseTask, label = 'after-response') => {
// Under the Next runtime, after() is invoked from a dynamic-import microtask (not synchronously);
// AsyncLocalStorage propagates across the .then continuation, and if after() is unavailable or
// throws (e.g. outside request scope) we degrade to a detached setTimeout(0) fire-and-forget.
if (process.env.NEXT_RUNTIME) {
void import('next/server').then(
({ after }) => {
try {
after(() => runTask(task, label));
} catch {
runFallbackAfterResponse(task, label);
}
},
() => runFallbackAfterResponse(task, label),
);
return;
}
runFallbackAfterResponse(task, label);
};
+23
View File
@@ -77,6 +77,29 @@ for the complete setup process,
including software installation, project configuration,
Docker service startup, and database migrations.
### Hono-Lite Dev Runtime (POC)
Alongside the classic `bun run dev` (Next + Vite),
the `apps/server` package ships a standalone Hono dev runtime —
`bun run dev:hono-lite` boots Hono on `:3011` and Vite on `:9876`
with **no Next.js process**.
It is intended for fast inner-loop work on the backend without
paying the Next dev-server cost,
and is currently a T1 dev-only POC
(no gray-release machinery, no production deploy target).
Typical flow:
```bash
bun run dev:docker # Docker services (Postgres, Redis, RustFS, SearXNG)
bun run dev:hono-lite # Hono + Vite, no Next
bun run dev:login # open the local dev-login URL in your browser
open http://localhost:9876
```
See [`apps/server/README.md`](https://github.com/lobehub/lobehub/blob/canary/apps/server/README.md)
for the routes served, port overrides, known gaps, and troubleshooting.
## Code Style and Contribution Guide
In the LobeHub project, we place great emphasis on the quality and consistency of the code. For this reason, we have established a series of code style standards and contribution processes to ensure that every developer can smoothly participate in the project. Here are the code style and contribution guidelines you need to follow as a developer.
+23
View File
@@ -72,6 +72,29 @@ lobehub/
了解完整的环境搭建流程,
包括软件安装、项目配置、Docker 服务启动和数据库迁移等步骤。
### Hono-Lite 开发运行时(POC
除经典的 `bun run dev`Next + Vite)之外,
`apps/server` 包还提供了独立的 Hono 开发运行时 ——
`bun run dev:hono-lite` 会在 `:3011` 启动 Hono、在 `:9876` 启动 Vite
**完全不启动 Next.js 进程**。
该模式用于在不付出 Next 开发服务器成本的前提下,
专注于后端的快速内循环开发,
目前仅作为 T1 开发期 POC 提供
(不包含灰度发布机制,亦不作为生产部署目标)。
典型启动流程:
```bash
bun run dev:docker # Docker 服务(Postgres、Redis、RustFS、SearXNG
bun run dev:hono-lite # Hono + Vite,无 Next
bun run dev:login # 在浏览器中打开本地开发登录链接
open http://localhost:9876
```
可服务的路由清单、端口覆盖、已知缺口与故障排查请参见
[`apps/server/README.md`](https://github.com/lobehub/lobehub/blob/canary/apps/server/README.md)。
## 代码风格与贡献指南
在 LobeHub 项目中,我们十分重视代码的质量和一致性。为此,我们制定了一系列的代码风格规范和贡献流程,以确保每位开发者都能顺利地参与到项目中。以下是你作为开发者需要遵守的代码风格和贡献准则。
+4
View File
@@ -71,6 +71,9 @@
"dev:docker": "docker compose -f docker-compose/dev/docker-compose.yml up -d --wait postgresql redis rustfs searxng",
"dev:docker:down": "docker compose -f docker-compose/dev/docker-compose.yml down",
"dev:docker:reset": "docker compose -f docker-compose/dev/docker-compose.yml down -v && rm -rf docker-compose/dev/data && npm run dev:docker && pnpm db:migrate",
"dev:hono:server": "npx vite-node --watch --config scripts/viteNodeServer.config.ts apps/server/src/hono/standalone.ts",
"dev:hono-lite": "tsx scripts/devHonoLite.mts",
"dev:login": "cross-env LOBE_DEV_TOPOLOGY=hono-lite tsx scripts/devLocalLogin.mts",
"dev:next": "next dev -p 3010",
"dev:spa": "vite --port 9876",
"dev:spa:auth": "cross-env AUTH=true vite --port 3013",
@@ -551,6 +554,7 @@
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"vite": "8.0.14",
"vite-node": "3.2.4",
"vite-plugin-pwa": "^1.2.0",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "3.2.6"
@@ -0,0 +1,7 @@
import type { emailHarmony } from 'better-auth-harmony';
export type BusinessEmailHarmonyOptions = NonNullable<Parameters<typeof emailHarmony>[0]>;
export const businessEmailHarmonyOptions = {
allowNormalizedSignin: false,
} satisfies BusinessEmailHarmonyOptions;
+1 -2
View File
@@ -1,6 +1,5 @@
import { type LobeChatDatabase } from '@lobechat/database';
import debug from 'debug';
import { type NextRequest } from 'next/server';
import { LOBE_CHAT_AUTH_HEADER } from '@/envs/auth';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
@@ -27,7 +26,7 @@ export const createAsyncContextInner = async (params?: {
export type AsyncContext = Awaited<ReturnType<typeof createAsyncContextInner>>;
export const createAsyncRouteContext = async (request: NextRequest): Promise<AsyncContext> => {
export const createAsyncRouteContext = async (request: Request): Promise<AsyncContext> => {
// for API-response caching see https://trpc.io/docs/v11/caching
log('Creating async route context');
+2 -3
View File
@@ -2,7 +2,6 @@ import { type Context as OtContext } from '@lobechat/observability-otel/api';
import { type ClientSecretPayload } from '@lobechat/types';
import { parse } from 'cookie';
import debug from 'debug';
import { type NextRequest } from 'next/server';
import { auth } from '@/auth';
import { getServerDB } from '@/database/core/db-adaptor';
@@ -17,7 +16,7 @@ import { isApiKeyExpired, validateApiKeyFormat } from '@/utils/apiKey';
const log = debug('lobe-trpc:lambda:context');
const LOBE_CHAT_API_KEY_HEADER = 'X-API-Key';
const extractClientIp = (request: NextRequest): string | undefined => {
const extractClientIp = (request: Request): string | undefined => {
const forwardedFor = request.headers.get('x-forwarded-for');
if (forwardedFor) {
const ip = forwardedFor.split(',')[0]?.trim();
@@ -115,7 +114,7 @@ export type LambdaContext = Awaited<ReturnType<typeof createContextInner>>;
* Creates context for an incoming request
* @link https://trpc.io/docs/v11/context
*/
export const createLambdaContext = async (request: NextRequest): Promise<LambdaContext> => {
export const createLambdaContext = async (request: Request): Promise<LambdaContext> => {
// we have a special header to debug the api endpoint in development mode
// IT WON'T GO INTO PRODUCTION ANYMORE
const isDebugApi = request.headers.get('lobe-auth-dev-backend-api') === '1';
+2 -4
View File
@@ -1,5 +1,3 @@
import { type NextRequest } from 'next/server';
/**
* Prepare Request object for tRPC fetchRequestHandler
*
@@ -10,10 +8,10 @@ import { type NextRequest } from 'next/server';
* By cloning the Request object, we create an independent body stream that tRPC can safely read.
*
* @see https://github.com/vercel/next.js/issues/83453
* @param req - The original NextRequest object
* @param req - The original Request object
* @returns A cloned Request object with an independent body stream
*/
export function prepareRequestForTRPC(req: NextRequest): Request {
export function prepareRequestForTRPC(req: Request): Request {
// Clone the Request to create an independent body stream
// This ensures tRPC can read the body even if the original request's body was disturbed
return req.clone();
+199
View File
@@ -0,0 +1,199 @@
import { type ChildProcess, spawn } from 'node:child_process';
import net from 'node:net';
import dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';
import { applyDefaultDevTopologyEnv, resolveDevHonoPort } from './devTopology';
process.title = 'lobe-dev-hono-lite';
const env = process.env.NODE_ENV || 'development';
const isWindows = process.platform === 'win32';
const shellEnv = Object.entries(process.env).reduce<Record<string, string>>(
(acc, [key, value]) => {
if (typeof value === 'string') acc[key] = value;
return acc;
},
{},
);
const dotenvEnv: Record<string, string> = {};
const dotenvResult = dotenv.config({
override: true,
path: ['.env', `.env.${env}`, `.env.${env}.local`],
processEnv: dotenvEnv,
});
if (dotenvResult.parsed) {
const expanded = dotenvExpand.expand({
parsed: dotenvResult.parsed,
processEnv: { ...dotenvEnv, ...shellEnv },
});
Object.assign(process.env, expanded.parsed, shellEnv);
}
(process.env as Record<string, string | undefined>).NODE_ENV ||= 'development';
process.env.LOBE_DEV_TOPOLOGY = 'hono-lite';
applyDefaultDevTopologyEnv(process.env);
const HONO_HOST = 'localhost';
const HONO_PORT = resolveDevHonoPort(process.env);
const HONO_READY_TIMEOUT_MS = 180_000;
const HONO_READY_RETRY_MS = 400;
const FORCE_KILL_TIMEOUT_MS = 5_000;
const npmCommand = isWindows ? 'npm.cmd' : 'npm';
let honoProcess: ChildProcess | undefined;
let viteProcess: ChildProcess | undefined;
let shuttingDown = false;
const runNpmScript = (scriptName: string) =>
spawn(npmCommand, ['run', scriptName], {
detached: !isWindows,
env: process.env,
stdio: 'inherit',
shell: isWindows,
});
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const isPortOpen = (host: string, port: number) =>
new Promise<boolean>((resolve) => {
const socket = net.createConnection({ host, port });
const onDone = (result: boolean) => {
socket.removeAllListeners();
socket.destroy();
resolve(result);
};
socket.once('connect', () => onDone(true));
socket.once('error', () => onDone(false));
socket.setTimeout(1_000, () => onDone(false));
});
const waitForHonoReady = async () => {
const startedAt = Date.now();
while (Date.now() - startedAt < HONO_READY_TIMEOUT_MS) {
if (await isPortOpen(HONO_HOST, HONO_PORT)) return;
await wait(HONO_READY_RETRY_MS);
}
throw new Error(
`Hono server was not ready within ${HONO_READY_TIMEOUT_MS / 1000}s on ${HONO_HOST}:${HONO_PORT}`,
);
};
const isChildAlive = (child: ChildProcess) =>
!child.killed && child.exitCode === null && child.signalCode === null;
const sendKillSignal = (child: ChildProcess, signal: NodeJS.Signals) => {
if (!isChildAlive(child) || !child.pid) return;
try {
if (!isWindows) {
try {
process.kill(-child.pid, signal);
return;
} catch {
// process group kill failed; fall through to direct kill
}
}
child.kill(signal);
} catch {
// child already gone
}
};
const terminateChild = (child?: ChildProcess) => {
if (!child) return;
sendKillSignal(child, 'SIGTERM');
};
const forceKillChild = (child?: ChildProcess) => {
if (!child) return;
sendKillSignal(child, 'SIGKILL');
};
const shutdownAll = (signal: NodeJS.Signals) => {
if (shuttingDown) return;
shuttingDown = true;
terminateChild(viteProcess);
terminateChild(honoProcess);
process.exitCode = signal === 'SIGINT' ? 130 : 143;
const forceKillTimer = setTimeout(() => {
forceKillChild(viteProcess);
forceKillChild(honoProcess);
}, FORCE_KILL_TIMEOUT_MS);
forceKillTimer.unref();
};
const watchChildExit = (child: ChildProcess, name: 'hono' | 'vite') => {
child.once('exit', (code, signal) => {
if (!shuttingDown) {
console.error(
`${name} exited unexpectedly (code: ${code ?? 'null'}, signal: ${signal ?? 'null'})`,
);
shutdownAll('SIGTERM');
}
});
};
const main = async () => {
const forwardedSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
for (const sig of forwardedSignals) {
process.once(sig, () => shutdownAll(sig));
}
process.on('uncaughtException', (error) => {
console.error('❌ uncaught exception in dev hono-lite:', error);
shutdownAll('SIGTERM');
});
process.on('unhandledRejection', (reason) => {
console.error('❌ unhandled rejection in dev hono-lite:', reason);
shutdownAll('SIGTERM');
});
process.on('exit', () => {
forceKillChild(viteProcess);
forceKillChild(honoProcess);
});
console.log(`🚀 Starting hono-lite topology (Hono ${HONO_HOST}:${HONO_PORT} + Vite, no Next)`);
honoProcess = runNpmScript('dev:hono:server');
watchChildExit(honoProcess, 'hono');
try {
await waitForHonoReady();
} catch (error) {
if (!shuttingDown) {
console.error('❌ Hono server failed to start:', error);
shutdownAll('SIGTERM');
}
return;
}
if (shuttingDown) return;
console.log(`✅ Hono server ready on ${HONO_HOST}:${HONO_PORT}, starting Vite`);
viteProcess = runNpmScript('dev:spa');
watchChildExit(viteProcess, 'vite');
await Promise.race([
new Promise((resolve) => honoProcess?.once('exit', resolve)),
new Promise((resolve) => viteProcess?.once('exit', resolve)),
]);
};
void main().catch((error) => {
console.error('❌ dev hono-lite failed:', error);
shutdownAll('SIGTERM');
});
+57
View File
@@ -0,0 +1,57 @@
import { spawn } from 'node:child_process';
import dotenv from 'dotenv';
import devTopology from './devTopology';
const { applyDefaultDevTopologyEnv } = devTopology;
dotenv.config();
process.env.LOBE_DEV_TOPOLOGY ||= 'hono-lite';
const devTopologyConfig = applyDefaultDevTopologyEnv(process.env);
process.title = 'lobe-dev-login';
const readArg = (name: string) => {
const index = process.argv.indexOf(name);
return index === -1 ? undefined : process.argv[index + 1];
};
const resolveBrowserOpenCommand = (url: string) => {
if (process.platform === 'win32') {
return { args: ['url.dll,FileProtocolHandler', url], cmd: 'rundll32' };
}
return {
args: [url],
cmd: process.platform === 'darwin' ? 'open' : 'xdg-open',
};
};
const openBrowser = (url: string) => {
const command = resolveBrowserOpenCommand(url);
const child = spawn(command.cmd, command.args, {
detached: true,
stdio: 'ignore',
shell: process.platform === 'win32',
});
child.once('error', (error) => {
console.error(`Failed to open browser: ${error.message}`);
console.error(url);
});
child.unref();
};
const email = readArg('--email') || process.env.LOBE_DEV_LOGIN_EMAIL || 'dev@local.test';
const name = readArg('--name') || process.env.LOBE_DEV_LOGIN_NAME || 'Local Dev';
const callbackURL = readArg('--callback') || '/';
const url = new URL('/api/auth/dev/local-login', devTopologyConfig.appUrl);
url.searchParams.set('email', email);
url.searchParams.set('name', name);
url.searchParams.set('callbackURL', callbackURL);
console.log(`Opening local dev login URL: ${url.toString()}`);
openBrowser(url.toString());
+2
View File
@@ -7,6 +7,8 @@ import { pathToFileURL } from 'node:url';
import dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';
process.title = 'lobe-dev';
interface DevProcessHandle {
directPid?: number;
groupPid?: number;
+199
View File
@@ -0,0 +1,199 @@
export type DevTopology = 'hono' | 'hono-lite' | 'next' | 'vite';
type Env = Record<string, string | undefined>;
interface DevTopologyStrategy {
apiRuntime: 'next' | 'none';
defaultAPITarget: (env: Env) => string;
defaultAppUrl: (env: Env) => string;
defaultHonoTarget?: (env: Env) => string;
defaultInternalAppUrl?: (env: Env) => string;
honoRuntime: 'standalone' | 'none';
nativeNextRuntimeEnv?: readonly string[];
nextBundler: 'turbopack' | 'webpack' | 'none';
nextRouteRuntime: 'hono' | 'next' | 'none';
shouldProxyAPI: (env: Env) => boolean;
topology: DevTopology;
}
interface DevTopologyConfig {
apiProxy: Record<string, { changeOrigin: true; target: string; ws: true }> | undefined;
apiRuntime: DevTopologyStrategy['apiRuntime'];
apiTarget: string;
appUrl: string;
honoRuntime: DevTopologyStrategy['honoRuntime'];
honoTarget: string | undefined;
internalAppUrl: string | undefined;
nextBundler: DevTopologyStrategy['nextBundler'];
nextRouteRuntime: DevTopologyStrategy['nextRouteRuntime'];
topology: DevTopology;
}
const DEFAULT_API_HOST = 'localhost';
const DEFAULT_API_PORT = 3010;
const DEFAULT_HONO_HOST = 'localhost';
const DEFAULT_HONO_PORT = 3011;
const DEFAULT_VITE_HOST = 'localhost';
const DEFAULT_VITE_PORT = 9876;
export const API_PROXY_PATTERN = '^/(?:api|oidc|trpc|webapi|market|f)(?:/|$)';
const AUTH_NATIVE_NEXT_RUNTIME_ENV = [
'LOBE_API_AUTH_RUNTIME',
'LOBE_API_AUTH_CHECK_USER_RUNTIME',
'LOBE_API_AUTH_RESOLVE_USERNAME_RUNTIME',
'LOBE_OIDC_CALLBACK_DESKTOP_RUNTIME',
'LOBE_OIDC_CLEAR_SESSION_RUNTIME',
'LOBE_OIDC_CONSENT_RUNTIME',
'LOBE_OIDC_HANDOFF_RUNTIME',
'LOBE_OIDC_PROVIDER_RUNTIME',
] as const;
const createLocalUrl = (host: string, port: number) => `http://${host}:${port}`;
export const resolveDevAPIPort = (env: Env = process.env): number => {
const parsed = Number.parseInt(env.PORT ?? '', 10);
return Number.isNaN(parsed) ? DEFAULT_API_PORT : parsed;
};
export const resolveVitePort = (env: Env = process.env): number => {
const parsed = Number.parseInt(env.VITE_PORT ?? '', 10);
return Number.isNaN(parsed) ? DEFAULT_VITE_PORT : parsed;
};
export const resolveDevHonoPort = (env: Env = process.env): number => {
const parsed = Number.parseInt(env.HONO_PORT ?? '', 10);
return Number.isNaN(parsed) ? DEFAULT_HONO_PORT : parsed;
};
const resolveDefaultAPITarget = (env: Env) =>
createLocalUrl(DEFAULT_API_HOST, resolveDevAPIPort(env));
const resolveDefaultViteOrigin = (env: Env) =>
createLocalUrl(DEFAULT_VITE_HOST, resolveVitePort(env));
const resolveDefaultHonoTarget = (env: Env) =>
createLocalUrl(DEFAULT_HONO_HOST, resolveDevHonoPort(env));
const devTopologyStrategies: Record<DevTopology, DevTopologyStrategy> = {
'hono': {
apiRuntime: 'next',
defaultAPITarget: resolveDefaultAPITarget,
defaultAppUrl: resolveDefaultAPITarget,
defaultHonoTarget: resolveDefaultHonoTarget,
defaultInternalAppUrl: resolveDefaultAPITarget,
honoRuntime: 'standalone',
nativeNextRuntimeEnv: AUTH_NATIVE_NEXT_RUNTIME_ENV,
nextBundler: 'webpack',
nextRouteRuntime: 'hono',
shouldProxyAPI: () => true,
topology: 'hono',
},
'hono-lite': {
apiRuntime: 'none',
defaultAPITarget: resolveDefaultHonoTarget,
defaultAppUrl: resolveDefaultViteOrigin,
defaultHonoTarget: resolveDefaultHonoTarget,
defaultInternalAppUrl: resolveDefaultHonoTarget,
honoRuntime: 'standalone',
nextBundler: 'none',
nextRouteRuntime: 'none',
shouldProxyAPI: () => true,
topology: 'hono-lite',
},
'next': {
apiRuntime: 'next',
defaultAPITarget: resolveDefaultAPITarget,
defaultAppUrl: resolveDefaultAPITarget,
defaultInternalAppUrl: resolveDefaultAPITarget,
honoRuntime: 'none',
nextBundler: 'turbopack',
nextRouteRuntime: 'next',
shouldProxyAPI: () => true,
topology: 'next',
},
'vite': {
apiRuntime: 'none',
defaultAPITarget: resolveDefaultAPITarget,
defaultAppUrl: resolveDefaultViteOrigin,
honoRuntime: 'none',
nextBundler: 'none',
nextRouteRuntime: 'none',
shouldProxyAPI: (env) => Boolean(env.LOBE_DEV_API_TARGET),
topology: 'vite',
},
};
export const normalizeDevTopology = (value: string | undefined): DevTopology => {
const normalized = value?.trim().toLowerCase();
if (
normalized === 'hono' ||
normalized === 'hono-lite' ||
normalized === 'next' ||
normalized === 'vite'
)
return normalized;
return 'next';
};
export const resolveDevTopologyConfig = (env: Env = process.env): DevTopologyConfig => {
const topology = normalizeDevTopology(env.LOBE_DEV_TOPOLOGY);
const strategy = devTopologyStrategies[topology];
const apiTarget = env.LOBE_DEV_API_TARGET || strategy.defaultAPITarget(env);
const appUrl = env.LOBE_DEV_APP_URL || strategy.defaultAppUrl(env);
const honoTarget = env.LOBE_DEV_HONO_TARGET || strategy.defaultHonoTarget?.(env);
const internalAppUrl =
env.LOBE_DEV_INTERNAL_APP_URL || strategy.defaultInternalAppUrl?.(env) || undefined;
const shouldProxyAPI = strategy.shouldProxyAPI(env);
return {
apiProxy: shouldProxyAPI
? {
[API_PROXY_PATTERN]: {
changeOrigin: true,
target: apiTarget,
ws: true,
},
}
: undefined,
apiRuntime: strategy.apiRuntime,
apiTarget,
appUrl,
honoRuntime: strategy.honoRuntime,
honoTarget,
internalAppUrl,
nextBundler: strategy.nextBundler,
nextRouteRuntime: strategy.nextRouteRuntime,
topology,
};
};
export const applyDefaultDevTopologyEnv = (env: Env = process.env) => {
const config = resolveDevTopologyConfig(env);
env.LOBE_DEV_TOPOLOGY = config.topology;
env.APP_URL = config.appUrl;
if (config.internalAppUrl) env.INTERNAL_APP_URL = config.internalAppUrl;
if (config.apiRuntime !== 'none' || config.apiProxy || env.LOBE_DEV_API_TARGET) {
env.LOBE_DEV_API_TARGET ||= config.apiTarget;
}
if (config.honoTarget) env.LOBE_DEV_HONO_TARGET ||= config.honoTarget;
if (config.topology === 'hono-lite') env.LOBE_DEV_AUTH_BOOTSTRAP ||= '1';
const strategy = devTopologyStrategies[config.topology];
for (const envName of strategy.nativeNextRuntimeEnv ?? []) {
env[envName] = 'next';
}
return config;
};
export default {
applyDefaultDevTopologyEnv,
resolveDevAPIPort,
resolveDevHonoPort,
};
+29
View File
@@ -0,0 +1,29 @@
import { readFileSync } from 'node:fs';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [
{
name: 'lobe-vite-node-raw-md',
load(id) {
const [filepath] = id.split('?');
if (!filepath.endsWith('.md')) return;
return `export default ${JSON.stringify(readFileSync(filepath, 'utf8'))};`;
},
},
tsconfigPaths(),
],
resolve: {
// pnpm links an older `@lobehub/editor` copy into
// `packages/editor-runtime/node_modules` while the repo root resolves `^4.16.1`.
// The inlined `@lobechat/editor-runtime` workspace package imports
// `@lobehub/editor/litexml-commands`, a subpath that only exists in the newer copy,
// so vite-node resolves it relative to the editor-runtime folder, lands on the older
// copy, and throws `Missing "./litexml-commands" specifier`. Deduping forces every
// `@lobehub/editor` import to the single root copy that ships the subpath.
dedupe: ['@lobehub/editor'],
},
});
+2
View File
@@ -20,6 +20,7 @@ import {
getVerificationEmailTemplate,
getVerificationOTPEmailTemplate,
} from '@/libs/better-auth/email-templates';
import { devLocalLogin } from '@/libs/better-auth/plugins/dev-local-login';
import { emailWhitelist } from '@/libs/better-auth/plugins/email-whitelist';
import { initBetterAuthSSOProviders } from '@/libs/better-auth/sso';
import { createSecondaryStorage, getTrustedOrigins } from '@/libs/better-auth/utils/config';
@@ -254,6 +255,7 @@ export function defineConfig(customOptions: CustomBetterAuthOptions) {
},
plugins: [
...customOptions.plugins,
devLocalLogin(),
emailWhitelist(),
expo(),
admin(),
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { isDevLocalLoginEnabled, resolveDevLocalLoginCallback } from './dev-local-login';
describe('dev local login plugin helpers', () => {
it('requires both development mode and the explicit bootstrap flag', () => {
expect(isDevLocalLoginEnabled({ LOBE_DEV_AUTH_BOOTSTRAP: '1', NODE_ENV: 'development' })).toBe(
true,
);
expect(isDevLocalLoginEnabled({ LOBE_DEV_AUTH_BOOTSTRAP: '0', NODE_ENV: 'development' })).toBe(
false,
);
expect(isDevLocalLoginEnabled({ LOBE_DEV_AUTH_BOOTSTRAP: '1', NODE_ENV: 'production' })).toBe(
false,
);
});
it('only accepts relative same-origin callback URLs', () => {
expect(resolveDevLocalLoginCallback('/settings')).toBe('/settings');
expect(resolveDevLocalLoginCallback(undefined)).toBe('/');
expect(resolveDevLocalLoginCallback('https://example.com')).toBe('/');
expect(resolveDevLocalLoginCallback('//example.com')).toBe('/');
});
});
@@ -0,0 +1,70 @@
import { APIError } from 'better-auth/api';
import { createAuthEndpoint } from 'better-auth/plugins';
import type { BetterAuthPlugin } from 'better-auth/types';
import { z } from 'zod';
interface DevLoginEnv {
LOBE_DEV_AUTH_BOOTSTRAP?: string;
NODE_ENV?: string;
}
const devLocalLoginQuerySchema = z.object({
callbackURL: z.string().optional(),
email: z.string().email(),
name: z.string().optional(),
});
export const isDevLocalLoginEnabled = (env: DevLoginEnv = process.env) =>
env.NODE_ENV === 'development' && env.LOBE_DEV_AUTH_BOOTSTRAP === '1';
export const resolveDevLocalLoginCallback = (value: string | undefined) => {
if (!value) return '/';
if (!value.startsWith('/') || value.startsWith('//')) return '/';
return value;
};
const resolveDevUserName = (email: string, value: string | undefined) => {
const name = value?.trim();
if (name) return name;
return email.split('@')[0] || 'Local Dev';
};
export const devLocalLogin = (): BetterAuthPlugin => ({
endpoints: {
devLocalLogin: createAuthEndpoint(
'/dev/local-login',
{
method: 'GET',
query: devLocalLoginQuerySchema,
},
async (ctx) => {
if (!isDevLocalLoginEnabled()) {
throw new APIError('NOT_FOUND', { message: 'dev local login is disabled' });
}
const email = ctx.query.email.toLowerCase();
const existing = await ctx.context.internalAdapter.findUserByEmail(email);
const user =
existing?.user ||
(await ctx.context.internalAdapter.createUser({
email,
emailVerified: true,
name: resolveDevUserName(email, ctx.query.name),
}));
const session = await ctx.context.internalAdapter.createSession(user.id);
await ctx.setSignedCookie(
ctx.context.authCookies.sessionToken.name,
session.token,
ctx.context.secret,
ctx.context.authCookies.sessionToken.options,
);
throw ctx.redirect(resolveDevLocalLoginCallback(ctx.query.callbackURL));
},
),
},
id: 'dev-local-login',
});
+2 -2
View File
@@ -25,9 +25,9 @@ export const convertHeadersToNodeHeaders = (nextHeaders: Headers): Record<string
/**
* Create a Node.js HTTP request object for OIDC Provider
* @param req Next.js request object
* @param req standard Request (also accepts NextRequest, which extends Request)
*/
export const createNodeRequest = async (req: NextRequest): Promise<IncomingMessage> => {
export const createNodeRequest = async (req: Request): Promise<IncomingMessage> => {
// Build URL object
const url = new URL(req.url);
+25 -4
View File
@@ -27,6 +27,8 @@ const isDev = process.env.NODE_ENV !== 'production';
const platform = isAuth ? 'auth' : isMobile ? 'mobile' : 'web';
const enableViteDevTools = process.env.LOBE_VITE_DEVTOOLS === 'true';
if (isDev) process.title = `lobe-dev-vite-${platform}`;
const resolveCommandExecutable = (cmd: string) => {
const pathValue = process.env.PATH;
if (!pathValue) return;
@@ -101,6 +103,12 @@ const openExternalBrowser = async (
});
};
const devTopology = process.env.LOBE_DEV_TOPOLOGY;
const honoLite = devTopology === 'hono-lite' || devTopology === 'hono';
const apiTarget = honoLite
? `http://localhost:${process.env.HONO_PORT || 3011}`
: `http://localhost:${process.env.PORT || 3010}`;
export default defineConfig({
base: isDev ? '/' : process.env.VITE_CDN_BASE || (isAuth ? '/_spa-auth/' : '/_spa/'),
build: {
@@ -246,8 +254,19 @@ export default defineConfig({
}
return () => {
const originalPrintUrls = server.printUrls.bind(server);
const printHonoUrl = () => {
if (process.env.LOBE_DEV_TOPOLOGY !== 'hono-lite') return;
const honoPort = process.env.HONO_PORT || '3011';
const honoUrl = `http://localhost:${honoPort}/`;
const colorUrl = (url: string) =>
c.cyan(url.replace(/:(\d+)\//, (_, port) => `:${c.bold(port)}/`));
info(` ${c.green('➜')} ${c.bold('Hono API')}: ${colorUrl(honoUrl)}`);
};
server.printUrls = () => {
if (isBundledDev) return;
originalPrintUrls();
printHonoUrl();
printProxyUrl();
};
};
@@ -302,10 +321,12 @@ export default defineConfig({
host: true,
port: 9876,
proxy: {
'/api': `http://localhost:${process.env.PORT || 3010}`,
'/oidc': `http://localhost:${process.env.PORT || 3010}`,
'/trpc': `http://localhost:${process.env.PORT || 3010}`,
'/webapi': `http://localhost:${process.env.PORT || 3010}`,
'/api': apiTarget,
'/f': apiTarget,
'/market': apiTarget,
'/oidc': apiTarget,
'/trpc': apiTarget,
'/webapi': apiTarget,
},
warmup: {
clientFiles: [