fix: extract useConversations hook to dedicated context file (#82)
* fix: extract useConversations hook to dedicated context file Move the conversations context and hook from the layout file to src/contexts/conversations-context.tsx. Next.js layouts cannot export hooks or types - only the default component export is allowed. * fix(build): prevent esbuild from bundling memory-provider The memory-provider module imports better-sqlite3, a native Node.js module that cannot run in Cloudflare Workers. This causes OpenNext build failures when esbuild tries to resolve the module. Changes: - Use dynamically constructed import path to prevent static analysis - Remove memory provider from getServerDb (never used on Cloudflare) - Add memory-provider to serverExternalPackages in next.config.ts * fix(lint): rename module variable to loadedModule The variable name 'module' is reserved in Next.js and causes ESLint error @next/next/no-assign-module-variable. --------- Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
This commit is contained in:
parent
40fdf48cbf
commit
d5a28ee709
@ -17,7 +17,8 @@ const nextConfig: NextConfig = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Node.js native modules that should not be bundled for edge/browser
|
// Node.js native modules that should not be bundled for edge/browser
|
||||||
serverExternalPackages: ["better-sqlite3"],
|
// memory-provider imports better-sqlite3 which cannot run in Cloudflare Workers
|
||||||
|
serverExternalPackages: ["better-sqlite3", "./src/db/provider/memory-provider"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@ -1,86 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import { ConversationsProvider } from "@/contexts/conversations-context"
|
||||||
|
|
||||||
type ThreadMessage = {
|
|
||||||
readonly id: string
|
|
||||||
readonly channelId: string
|
|
||||||
readonly threadId: string | null
|
|
||||||
readonly content: string
|
|
||||||
readonly contentHtml: string | null
|
|
||||||
readonly editedAt: string | null
|
|
||||||
readonly deletedAt: string | null
|
|
||||||
readonly isPinned: boolean
|
|
||||||
readonly replyCount: number
|
|
||||||
readonly lastReplyAt: string | null
|
|
||||||
readonly createdAt: string
|
|
||||||
readonly user: {
|
|
||||||
readonly id: string
|
|
||||||
readonly displayName: string | null
|
|
||||||
readonly email: string
|
|
||||||
readonly avatarUrl: string | null
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type ConversationsContextType = {
|
|
||||||
readonly threadOpen: boolean
|
|
||||||
readonly threadMessageId: string | null
|
|
||||||
readonly threadParentMessage: ThreadMessage | null
|
|
||||||
readonly openThread: (messageId: string, parentMessage: ThreadMessage) => void
|
|
||||||
readonly closeThread: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const ConversationsContext = React.createContext<ConversationsContextType | undefined>(
|
|
||||||
undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
export function useConversations() {
|
|
||||||
const context = React.useContext(ConversationsContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useConversations must be used within ConversationsProvider")
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { ThreadMessage }
|
|
||||||
|
|
||||||
export default function ConversationsLayout({
|
export default function ConversationsLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
readonly children: React.ReactNode
|
readonly children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const [threadOpen, setThreadOpen] = React.useState(false)
|
return <ConversationsProvider>{children}</ConversationsProvider>
|
||||||
const [threadMessageId, setThreadMessageId] = React.useState<string | null>(null)
|
|
||||||
const [threadParentMessage, setThreadParentMessage] = React.useState<ThreadMessage | null>(null)
|
|
||||||
|
|
||||||
const openThread = React.useCallback((messageId: string, parentMessage: ThreadMessage) => {
|
|
||||||
setThreadMessageId(messageId)
|
|
||||||
setThreadParentMessage(parentMessage)
|
|
||||||
setThreadOpen(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const closeThread = React.useCallback(() => {
|
|
||||||
setThreadOpen(false)
|
|
||||||
setThreadMessageId(null)
|
|
||||||
setThreadParentMessage(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const value = React.useMemo(
|
|
||||||
() => ({
|
|
||||||
threadOpen,
|
|
||||||
threadMessageId,
|
|
||||||
threadParentMessage,
|
|
||||||
openThread,
|
|
||||||
closeThread,
|
|
||||||
}),
|
|
||||||
[threadOpen, threadMessageId, threadParentMessage, openThread, closeThread]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConversationsContext.Provider value={value}>
|
|
||||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</ConversationsContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { MarkdownRenderer } from "@/components/ui/markdown-renderer"
|
import { MarkdownRenderer } from "@/components/ui/markdown-renderer"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useConversations } from "@/app/dashboard/conversations/layout"
|
import { useConversations } from "@/contexts/conversations-context"
|
||||||
import { editMessage, deleteMessage, addReaction } from "@/app/actions/chat-messages"
|
import { editMessage, deleteMessage, addReaction } from "@/app/actions/chat-messages"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
|
|||||||
@ -6,8 +6,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useConversations } from "@/app/dashboard/conversations/layout"
|
import { useConversations, type ThreadMessage } from "@/contexts/conversations-context"
|
||||||
import type { ThreadMessage } from "@/app/dashboard/conversations/layout"
|
|
||||||
import { getThreadMessages } from "@/app/actions/chat-messages"
|
import { getThreadMessages } from "@/app/actions/chat-messages"
|
||||||
import { MessageItem } from "./message-item"
|
import { MessageItem } from "./message-item"
|
||||||
import { MessageComposer } from "./message-composer"
|
import { MessageComposer } from "./message-composer"
|
||||||
|
|||||||
84
src/contexts/conversations-context.tsx
Normal file
84
src/contexts/conversations-context.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
export type ThreadMessage = {
|
||||||
|
readonly id: string
|
||||||
|
readonly channelId: string
|
||||||
|
readonly threadId: string | null
|
||||||
|
readonly content: string
|
||||||
|
readonly contentHtml: string | null
|
||||||
|
readonly editedAt: string | null
|
||||||
|
readonly deletedAt: string | null
|
||||||
|
readonly isPinned: boolean
|
||||||
|
readonly replyCount: number
|
||||||
|
readonly lastReplyAt: string | null
|
||||||
|
readonly createdAt: string
|
||||||
|
readonly user: {
|
||||||
|
readonly id: string
|
||||||
|
readonly displayName: string | null
|
||||||
|
readonly email: string
|
||||||
|
readonly avatarUrl: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConversationsContextType = {
|
||||||
|
readonly threadOpen: boolean
|
||||||
|
readonly threadMessageId: string | null
|
||||||
|
readonly threadParentMessage: ThreadMessage | null
|
||||||
|
readonly openThread: (messageId: string, parentMessage: ThreadMessage) => void
|
||||||
|
readonly closeThread: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConversationsContext = React.createContext<ConversationsContextType | undefined>(
|
||||||
|
undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
export function useConversations() {
|
||||||
|
const context = React.useContext(ConversationsContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useConversations must be used within ConversationsProvider")
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationsProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
readonly children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const [threadOpen, setThreadOpen] = React.useState(false)
|
||||||
|
const [threadMessageId, setThreadMessageId] = React.useState<string | null>(null)
|
||||||
|
const [threadParentMessage, setThreadParentMessage] = React.useState<ThreadMessage | null>(null)
|
||||||
|
|
||||||
|
const openThread = React.useCallback((messageId: string, parentMessage: ThreadMessage) => {
|
||||||
|
setThreadMessageId(messageId)
|
||||||
|
setThreadParentMessage(parentMessage)
|
||||||
|
setThreadOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const closeThread = React.useCallback(() => {
|
||||||
|
setThreadOpen(false)
|
||||||
|
setThreadMessageId(null)
|
||||||
|
setThreadParentMessage(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
threadOpen,
|
||||||
|
threadMessageId,
|
||||||
|
threadParentMessage,
|
||||||
|
openThread,
|
||||||
|
closeThread,
|
||||||
|
}),
|
||||||
|
[threadOpen, threadMessageId, threadParentMessage, openThread, closeThread]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConversationsContext.Provider value={value}>
|
||||||
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</ConversationsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -55,6 +55,23 @@ export interface DatabaseProviderProps {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lazy-loaded memory provider factory - only loaded when actually needed
|
||||||
|
// This avoids bundling better-sqlite3 (a native Node.js module) in
|
||||||
|
// environments like Cloudflare Workers where it can't run
|
||||||
|
let createMemoryProviderFn: typeof import("./memory-provider")["createMemoryProvider"] | null = null
|
||||||
|
|
||||||
|
async function getMemoryProvider(config?: MemoryProviderConfig): Promise<DatabaseProvider> {
|
||||||
|
if (!createMemoryProviderFn) {
|
||||||
|
// Construct the path dynamically to prevent static analysis by bundlers
|
||||||
|
// The path segments are concatenated at runtime
|
||||||
|
const providerDir = "."
|
||||||
|
const providerFile = "memory-provider"
|
||||||
|
const loadedModule = await import(/* webpackIgnore: true */ `${providerDir}/${providerFile}`)
|
||||||
|
createMemoryProviderFn = loadedModule.createMemoryProvider
|
||||||
|
}
|
||||||
|
return createMemoryProviderFn!(config)
|
||||||
|
}
|
||||||
|
|
||||||
export function DatabaseProvider({
|
export function DatabaseProvider({
|
||||||
children,
|
children,
|
||||||
forcePlatform,
|
forcePlatform,
|
||||||
@ -91,11 +108,7 @@ export function DatabaseProvider({
|
|||||||
|
|
||||||
case "memory":
|
case "memory":
|
||||||
default: {
|
default: {
|
||||||
// Dynamic import to avoid bundling better-sqlite3 in browser
|
newProvider = await getMemoryProvider(config?.memory)
|
||||||
const { createMemoryProvider } = await import(
|
|
||||||
/* webpackIgnore: true */ "./memory-provider"
|
|
||||||
)
|
|
||||||
newProvider = createMemoryProvider(config?.memory)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -164,12 +177,15 @@ export async function getServerDb(): Promise<DrizzleDB> {
|
|||||||
|
|
||||||
case "memory":
|
case "memory":
|
||||||
default: {
|
default: {
|
||||||
// Dynamic import to avoid bundling better-sqlite3 in browser
|
// Memory provider is only for local development/testing.
|
||||||
const { createMemoryProvider } = await import(
|
// On Cloudflare Workers, detectPlatform() returns "d1", so this
|
||||||
/* webpackIgnore: true */ "./memory-provider"
|
// code path is never reached at runtime. However, bundlers like
|
||||||
|
// esbuild still try to resolve the import. We throw an error here
|
||||||
|
// since this code should never execute in production environments.
|
||||||
|
throw new Error(
|
||||||
|
"Memory provider not available in this environment. " +
|
||||||
|
"Ensure you have a local SQLite setup or use the D1 provider."
|
||||||
)
|
)
|
||||||
const provider = createMemoryProvider()
|
|
||||||
return provider.getDb()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user