>
+
+ export type ComponentWithRef = FC<
+ ComponentWithRefType
+ >
+ export type ComponentWithRefType
= Prettify<
+ ComponentType
& {
+ ref?: React.Ref[
+ }
+ >
+
+ export type ComponentType] = {
+ className?: string
+ } & PropsWithChildren &
+ P
+}
+
+declare module 'react' {
+ export interface AriaAttributes {
+ 'data-testid'?: string
+ 'data-hide-in-print'?: boolean
+ }
+}
diff --git a/be/apps/dashboard/src/lib/dev.tsx b/be/apps/dashboard/src/lib/dev.tsx
new file mode 100644
index 00000000..d0b42646
--- /dev/null
+++ b/be/apps/dashboard/src/lib/dev.tsx
@@ -0,0 +1,59 @@
+declare const APP_DEV_CWD: string
+export const attachOpenInEditor = (stack: string) => {
+ const lines = stack.split('\n')
+ return lines.map((line) => {
+ // A line like this: at App (http://localhost:5173/src/App.tsx?t=1720527056591:41:9)
+ // Find the `localhost` part and open the file in the editor
+ if (!line.includes('at ')) {
+ return line
+ }
+ const match = line.match(/(http:\/\/localhost:\d+\/[^:]+):(\d+):(\d+)/)
+
+ if (match) {
+ const [o] = match
+
+ // Find `@fs/`
+ // Like: `http://localhost:5173/@fs/Users/innei/git/work/rss3/follow/node_modules/.vite/deps/chunk-RPCDYKBN.js?v=757920f2:11548:26`
+ const realFsPath = o.split('@fs')[1]
+
+ if (realFsPath) {
+ return (
+ // Delete `v=` hash, like `v=757920f2`
+
+ {line}
+
+ )
+ } else {
+ // at App (http://localhost:5173/src/App.tsx?t=1720527056591:41:9)
+ const srcFsPath = o.split('/src')[1]
+
+ if (srcFsPath) {
+ const fs = srcFsPath.replace(/\?t=[a-f0-9]+/, '')
+
+ return (
+
+ {line}
+
+ )
+ }
+ }
+ }
+
+ return line
+ })
+}
+// http://localhost:5173/src/App.tsx?t=1720527056591:41:9
+const openInEditor = (file: string) => {
+ fetch(`/__open-in-editor?file=${encodeURIComponent(`${file}`)}`)
+}
diff --git a/be/apps/dashboard/src/lib/dom.ts b/be/apps/dashboard/src/lib/dom.ts
new file mode 100644
index 00000000..f2d7902c
--- /dev/null
+++ b/be/apps/dashboard/src/lib/dom.ts
@@ -0,0 +1,23 @@
+import type { ReactEventHandler } from "react"
+
+export const stopPropagation: ReactEventHandler = (e) => e.stopPropagation()
+
+export const preventDefault: ReactEventHandler = (e) => e.preventDefault()
+
+export const nextFrame = (fn: (...args: any[]) => any) => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ fn()
+ })
+ })
+}
+
+export const getElementTop = (element: HTMLElement) => {
+ let actualTop = element.offsetTop
+ let current = element.offsetParent as HTMLElement
+ while (current !== null) {
+ actualTop += current.offsetTop
+ current = current.offsetParent as HTMLElement
+ }
+ return actualTop
+}
diff --git a/be/apps/dashboard/src/lib/get-strict-context.tsx b/be/apps/dashboard/src/lib/get-strict-context.tsx
new file mode 100644
index 00000000..ce0be7b6
--- /dev/null
+++ b/be/apps/dashboard/src/lib/get-strict-context.tsx
@@ -0,0 +1,36 @@
+import * as React from 'react'
+
+function getStrictContext(
+ name?: string,
+): readonly [
+ ({
+ value,
+ children,
+ }: {
+ value: T
+ children?: React.ReactNode
+ }) => React.JSX.Element,
+ () => T,
+] {
+ const Context = React.createContext(undefined)
+
+ const Provider = ({
+ value,
+ children,
+ }: {
+ value: T
+ children?: React.ReactNode
+ }) => {children}
+
+ const useSafeContext = () => {
+ const ctx = React.use(Context)
+ if (ctx === undefined) {
+ throw new Error(`useContext must be used within ${name ?? 'a Provider'}`)
+ }
+ return ctx
+ }
+
+ return [Provider, useSafeContext] as const
+}
+
+export { getStrictContext }
diff --git a/be/apps/dashboard/src/lib/jotai.ts b/be/apps/dashboard/src/lib/jotai.ts
new file mode 100644
index 00000000..f6bdc3e8
--- /dev/null
+++ b/be/apps/dashboard/src/lib/jotai.ts
@@ -0,0 +1,39 @@
+import type { Atom, PrimitiveAtom } from 'jotai'
+import { createStore, useAtom, useAtomValue, useSetAtom } from 'jotai'
+import { selectAtom } from 'jotai/utils'
+import { useCallback } from 'react'
+
+export const jotaiStore = createStore()
+
+export const createAtomAccessor = (atom: PrimitiveAtom) =>
+ [
+ () => jotaiStore.get(atom),
+ (value: T) => jotaiStore.set(atom, value),
+ ] as const
+
+const options = { store: jotaiStore }
+/**
+ * @param atom - jotai
+ * @returns - [atom, useAtom, useAtomValue, useSetAtom, jotaiStore.get, jotaiStore.set]
+ */
+export const createAtomHooks = (atom: PrimitiveAtom) =>
+ [
+ atom,
+ () => useAtom(atom, options),
+ () => useAtomValue(atom, options),
+ () => useSetAtom(atom, options),
+ ...createAtomAccessor(atom),
+ ] as const
+
+export const createAtomSelector = (atom: Atom) => {
+ const useHook = (selector: (a: T) => R, deps: any[] = []) =>
+ useAtomValue(
+ selectAtom(
+ atom,
+ useCallback((a) => selector(a as T), deps),
+ ),
+ )
+
+ useHook.__atom = atom
+ return useHook
+}
diff --git a/be/apps/dashboard/src/lib/ns.ts b/be/apps/dashboard/src/lib/ns.ts
new file mode 100644
index 00000000..5bb6f061
--- /dev/null
+++ b/be/apps/dashboard/src/lib/ns.ts
@@ -0,0 +1,11 @@
+const ns = 'app'
+export const getStorageNS = (key: string) => `${ns}:${key}`
+
+export const clearStorage = () => {
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i)
+ if (key && key.startsWith(ns)) {
+ localStorage.removeItem(key)
+ }
+ }
+}
diff --git a/be/apps/dashboard/src/lib/query-client.ts b/be/apps/dashboard/src/lib/query-client.ts
new file mode 100644
index 00000000..d66f4f61
--- /dev/null
+++ b/be/apps/dashboard/src/lib/query-client.ts
@@ -0,0 +1,22 @@
+import { QueryClient } from '@tanstack/react-query'
+import { FetchError } from 'ofetch'
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ gcTime: Infinity,
+ retryDelay: 1000,
+ retry(failureCount, error) {
+ console.error(error)
+ if (error instanceof FetchError && error.statusCode === undefined) {
+ return false
+ }
+
+ return !!(3 - failureCount)
+ },
+ // throwOnError: import.meta.env.DEV,
+ },
+ },
+})
+
+export { queryClient }
diff --git a/be/apps/dashboard/src/lib/utils.ts b/be/apps/dashboard/src/lib/utils.ts
new file mode 100644
index 00000000..28b74383
--- /dev/null
+++ b/be/apps/dashboard/src/lib/utils.ts
@@ -0,0 +1 @@
+export { cn } from './cn'
diff --git a/be/apps/dashboard/src/main.tsx b/be/apps/dashboard/src/main.tsx
new file mode 100644
index 00000000..273b41af
--- /dev/null
+++ b/be/apps/dashboard/src/main.tsx
@@ -0,0 +1,19 @@
+import './styles/index.css'
+
+import * as React from 'react'
+import { createRoot } from 'react-dom/client'
+import { RouterProvider } from 'react-router'
+
+import { router } from './router'
+
+const $container = document.querySelector('#root') as HTMLElement
+
+if (import.meta.env.DEV) {
+ const { start } = await import('react-scan')
+ start()
+}
+createRoot($container).render(
+
+
+ ,
+)
diff --git a/be/apps/dashboard/src/modules/.gitkeep b/be/apps/dashboard/src/modules/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/be/apps/dashboard/src/pages/(main)/index.sync.tsx b/be/apps/dashboard/src/pages/(main)/index.sync.tsx
new file mode 100644
index 00000000..483f0daf
--- /dev/null
+++ b/be/apps/dashboard/src/pages/(main)/index.sync.tsx
@@ -0,0 +1 @@
+export const Component = () => null
diff --git a/be/apps/dashboard/src/providers/context-menu-provider.tsx b/be/apps/dashboard/src/providers/context-menu-provider.tsx
new file mode 100644
index 00000000..89de6e27
--- /dev/null
+++ b/be/apps/dashboard/src/providers/context-menu-provider.tsx
@@ -0,0 +1,150 @@
+import { Fragment, memo, useCallback, useEffect, useRef } from 'react'
+
+import type { FollowMenuItem } from '~/atoms/context-menu'
+import {
+ MenuItemSeparator,
+ MenuItemType,
+ useContextMenuState,
+} from '~/atoms/context-menu'
+import {
+ ContextMenu,
+ ContextMenuCheckboxItem,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuPortal,
+ ContextMenuSeparator,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+ ContextMenuTrigger,
+} from '@afilmory/ui'
+import { clsxm as cn } from '@afilmory/utils'
+import { nextFrame, preventDefault } from '~/lib/dom'
+
+export const ContextMenuProvider: Component = ({ children }) => (
+ <>
+ {children}
+
+ >
+)
+
+const Handler = () => {
+ const ref = useRef(null)
+ const [contextMenuState, setContextMenuState] = useContextMenuState()
+
+ useEffect(() => {
+ if (!contextMenuState.open) return
+ const triggerElement = ref.current
+ if (!triggerElement) return
+ // [ContextMenu] Add ability to control
+ // https://github.com/radix-ui/primitives/issues/1307#issuecomment-1689754796
+ triggerElement.dispatchEvent(
+ new MouseEvent('contextmenu', {
+ bubbles: true,
+ cancelable: true,
+ clientX: contextMenuState.position.x,
+ clientY: contextMenuState.position.y,
+ }),
+ )
+ }, [contextMenuState])
+
+ const handleOpenChange = useCallback(
+ (state: boolean) => {
+ if (state) return
+ if (!contextMenuState.open) return
+ setContextMenuState({ open: false })
+ contextMenuState.abortController.abort()
+ },
+ [contextMenuState, setContextMenuState],
+ )
+
+ return (
+
+
+
+ {contextMenuState.open &&
+ contextMenuState.menuItems.map((item, index) => {
+ const prevItem = contextMenuState.menuItems[index - 1]
+ if (
+ prevItem instanceof MenuItemSeparator &&
+ item instanceof MenuItemSeparator
+ ) {
+ return null
+ }
+
+ if (!prevItem && item instanceof MenuItemSeparator) {
+ return null
+ }
+ const nextItem = contextMenuState.menuItems[index + 1]
+ if (!nextItem && item instanceof MenuItemSeparator) {
+ return null
+ }
+ return
+ })}
+
+
+ )
+}
+
+const Item = memo(({ item }: { item: FollowMenuItem }) => {
+ const onClick = useCallback(() => {
+ if ('click' in item) {
+ // Here we need to delay one frame,
+ // so it's two raf's, in order to have `point-event: none` recorded by RadixOverlay after modal is invoked in a certain scenario,
+ // and the page freezes after modal is turned off.
+ nextFrame(() => {
+ item.click?.()
+ })
+ }
+ }, [item])
+ const itemRef = useRef(null)
+
+ switch (item.type) {
+ case MenuItemType.Separator: {
+ return
+ }
+ case MenuItemType.Action: {
+ const hasSubmenu = item.submenu.length > 0
+ const Wrapper = hasSubmenu
+ ? ContextMenuSubTrigger
+ : typeof item.checked === 'boolean'
+ ? ContextMenuCheckboxItem
+ : ContextMenuItem
+
+ const Sub = hasSubmenu ? ContextMenuSub : Fragment
+
+ return (
+
+
+ {!!item.icon && (
+
+ {item.icon}
+
+ )}
+ {item.label}
+
+ {hasSubmenu && (
+
+
+ {item.submenu.map((subItem, index) => (
+
+ ))}
+
+
+ )}
+
+ )
+ }
+ default: {
+ return null
+ }
+ }
+})
diff --git a/be/apps/dashboard/src/providers/event-provider.tsx b/be/apps/dashboard/src/providers/event-provider.tsx
new file mode 100644
index 00000000..2197d91e
--- /dev/null
+++ b/be/apps/dashboard/src/providers/event-provider.tsx
@@ -0,0 +1,43 @@
+import { throttle } from 'es-toolkit/compat'
+import { useIsomorphicLayoutEffect } from 'foxact/use-isomorphic-layout-effect'
+import { useStore } from 'jotai'
+import type { FC } from 'react'
+
+import { viewportAtom } from '~/atoms/viewport'
+
+export const EventProvider: FC = () => {
+ const store = useStore()
+ useIsomorphicLayoutEffect(() => {
+ const readViewport = throttle(() => {
+ const { innerWidth: w, innerHeight: h } = window
+ const sm = w >= 640
+ const md = w >= 768
+ const lg = w >= 1024
+ const xl = w >= 1280
+ const _2xl = w >= 1536
+ store.set(viewportAtom, {
+ sm,
+ md,
+ lg,
+ xl,
+ '2xl': _2xl,
+ h,
+ w,
+ })
+
+ const isMobile = window.innerWidth < 1024
+ document.documentElement.dataset.viewport = isMobile
+ ? 'mobile'
+ : 'desktop'
+ }, 16)
+
+ readViewport()
+
+ window.addEventListener('resize', readViewport)
+ return () => {
+ window.removeEventListener('resize', readViewport)
+ }
+ }, [])
+
+ return null
+}
diff --git a/be/apps/dashboard/src/providers/root-providers.tsx b/be/apps/dashboard/src/providers/root-providers.tsx
new file mode 100644
index 00000000..38e45076
--- /dev/null
+++ b/be/apps/dashboard/src/providers/root-providers.tsx
@@ -0,0 +1,35 @@
+import { QueryClientProvider } from '@tanstack/react-query'
+import { Provider } from 'jotai'
+import { LazyMotion, MotionConfig } from 'motion/react'
+import type { FC, PropsWithChildren } from 'react'
+
+import { ModalContainer } from '@afilmory/ui'
+import { Toaster } from '@afilmory/ui'
+import { jotaiStore } from '~/lib/jotai'
+import { queryClient } from '~/lib/query-client'
+import { Spring } from '@afilmory/utils'
+
+import { ContextMenuProvider } from './context-menu-provider'
+import { EventProvider } from './event-provider'
+import { SettingSync } from './setting-sync'
+import { StableRouterProvider } from './stable-router-provider'
+
+const loadFeatures = () =>
+ import('../framer-lazy-feature').then((res) => res.default)
+export const RootProviders: FC = ({ children }) => (
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+)
diff --git a/be/apps/dashboard/src/providers/setting-sync.tsx b/be/apps/dashboard/src/providers/setting-sync.tsx
new file mode 100644
index 00000000..85620e21
--- /dev/null
+++ b/be/apps/dashboard/src/providers/setting-sync.tsx
@@ -0,0 +1,11 @@
+import { useSyncThemeark } from '~/hooks/common'
+
+const useUISettingSync = () => {
+ useSyncThemeark()
+}
+
+export const SettingSync = () => {
+ useUISettingSync()
+
+ return null
+}
diff --git a/be/apps/dashboard/src/providers/stable-router-provider.tsx b/be/apps/dashboard/src/providers/stable-router-provider.tsx
new file mode 100644
index 00000000..a3553788
--- /dev/null
+++ b/be/apps/dashboard/src/providers/stable-router-provider.tsx
@@ -0,0 +1,50 @@
+import { useLayoutEffect } from 'react'
+import type { NavigateFunction } from 'react-router'
+import {
+ useLocation,
+ useNavigate,
+ useParams,
+ useSearchParams,
+} from 'react-router'
+
+import { setNavigate, setRoute } from '~/atoms/route'
+
+declare global {
+ export const router: {
+ navigate: NavigateFunction
+ }
+ interface Window {
+ router: typeof router
+ }
+}
+window.router = {
+ navigate() {},
+}
+
+/**
+ * Why this.
+ * Remix router always update immutable object when the router has any changes, lead to the component which uses router hooks re-render.
+ * This provider is hold a empty component, to store the router hooks value.
+ * And use our router hooks will not re-render the component when the router has any changes.
+ * Also it can access values outside of the component and provide a value selector
+ */
+export const StableRouterProvider = () => {
+ const [searchParams] = useSearchParams()
+ const params = useParams()
+ const nav = useNavigate()
+ const location = useLocation()
+
+ // NOTE: This is a hack to expose the navigate function to the window object, avoid to import `router` circular issue.
+ useLayoutEffect(() => {
+ window.router.navigate = nav
+
+ setRoute({
+ params,
+ searchParams,
+ location,
+ })
+ setNavigate({ fn: nav })
+ }, [searchParams, params, location, nav])
+
+ return null
+}
diff --git a/be/apps/dashboard/src/router.tsx b/be/apps/dashboard/src/router.tsx
new file mode 100644
index 00000000..9f60b996
--- /dev/null
+++ b/be/apps/dashboard/src/router.tsx
@@ -0,0 +1,19 @@
+import { createBrowserRouter } from 'react-router'
+
+import { App } from './App'
+import { ErrorElement } from './components/common/ErrorElement'
+import { NotFound } from './components/common/NotFound'
+import { routes } from './generated-routes'
+
+export const router = createBrowserRouter([
+ {
+ path: '/',
+ element: ,
+ children: routes,
+ errorElement: ,
+ },
+ {
+ path: '*',
+ element: ,
+ },
+])
diff --git a/be/apps/dashboard/src/store/.gitkeep b/be/apps/dashboard/src/store/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/be/apps/dashboard/src/styles/index.css b/be/apps/dashboard/src/styles/index.css
new file mode 100644
index 00000000..242bf8f7
--- /dev/null
+++ b/be/apps/dashboard/src/styles/index.css
@@ -0,0 +1 @@
+@import './tailwind.css';
diff --git a/be/apps/dashboard/src/styles/tailwind.css b/be/apps/dashboard/src/styles/tailwind.css
new file mode 100644
index 00000000..b01b516c
--- /dev/null
+++ b/be/apps/dashboard/src/styles/tailwind.css
@@ -0,0 +1,254 @@
+@import 'tailwindcss';
+@import 'tailwindcss-safe-area';
+
+@plugin "@tailwindcss/typography";
+@plugin '@egoist/tailwindcss-icons';
+@plugin "tailwind-scrollbar";
+@plugin 'tailwindcss-animate';
+
+@import '@pastel-palette/tailwindcss/dist/theme-oklch.css';
+
+@source "./src/**/*.{js,jsx,ts,tsx}";
+@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
+
+[data-hand-cursor='true'] {
+ --cursor-button: pointer;
+ --cursor-select: text;
+ --cursor-checkbox: pointer;
+ --cursor-link: pointer;
+ --cursor-menu: pointer;
+ --cursor-radio: pointer;
+ --cursor-switch: pointer;
+ --cursor-card: pointer;
+}
+
+:root {
+ --cursor-button: default;
+ --cursor-select: text;
+ --cursor-checkbox: default;
+ --cursor-link: pointer;
+ --cursor-menu: default;
+ --cursor-radio: default;
+ --cursor-switch: default;
+ --cursor-card: default;
+
+ --radius: 0.625rem;
+ /* Pastel provides semantic colors (background, text, accent, border, etc).
+ Keep only non-Pastel variables here. */
+
+ /* Shadcn compatibility vars mapped to Pastel tokens */
+ --background: var(--color-background);
+ --foreground: var(--color-text);
+ --card: var(--color-material-opaque);
+ --card-foreground: var(--color-text);
+ --popover: var(--color-material-medium);
+ --popover-foreground: var(--color-text);
+ --primary: var(--color-primary);
+ --primary-foreground: var(--color-white);
+ --secondary: var(--color-fill-secondary);
+ --secondary-foreground: var(--color-text);
+ --muted: var(--color-fill-tertiary);
+ --muted-foreground: var(--color-text-tertiary);
+ --accent: var(--color-accent);
+ --accent-foreground: var(--color-white);
+ --destructive: var(--color-red);
+ --border: var(--color-border);
+ --input: var(--color-border-secondary);
+ --ring: var(--color-primary);
+
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+:root,
+body {
+ @apply bg-background text-text;
+ @apply font-sans;
+ @apply text-base leading-normal;
+ @apply antialiased;
+ @apply selection:bg-accent selection:text-white;
+}
+
+/* Theme configuration */
+@theme {
+ /* Container */
+ --container-padding: 2rem;
+ --container-max-width-2xl: 1400px;
+
+ /* Custom cursors */
+ --cursor-button: var(--cursor-button);
+ --cursor-select: var(--cursor-select);
+ --cursor-checkbox: var(--cursor-checkbox);
+ --cursor-link: var(--cursor-link);
+ --cursor-menu: var(--cursor-menu);
+ --cursor-radio: var(--cursor-radio);
+ --cursor-switch: var(--cursor-switch);
+ --cursor-card: var(--cursor-card);
+
+ /* Blur */
+ --blur-background: 70px;
+
+ /* Box shadow */
+ --box-shadow-context-menu:
+ rgba(0, 0, 0, 0.067) 0px 3px 8px, rgba(0, 0, 0, 0.067) 0px 2px 5px,
+ rgba(0, 0, 0, 0.067) 0px 1px 1px;
+
+ /* Font */
+ --text-large-title: 1.625rem;
+ --text-large-title--line-height: 2rem;
+
+ --text-title1: 1.375rem;
+ --text-title1--line-height: 1.625rem;
+
+ --text-title2: 1.0625rem;
+ --text-title2--line-height: 1.375rem;
+
+ --text-title3: 0.9375rem;
+ --text-title3--line-height: 1.25rem;
+
+ --text-headline: 0.8125rem;
+ --text-headline--line-height: 1rem;
+
+ --text-body: 0.8125rem;
+ --text-body--line-height: 1rem;
+
+ --text-callout: 0.75rem;
+ --text-callout--line-height: 0.9375rem;
+
+ --text-subheadline: 0.6875rem;
+ --text-subheadline--line-height: 0.875rem;
+
+ --text-footnote: 0.625rem;
+ --text-footnote--line-height: 0.8125rem;
+
+ --text-caption: 0.625rem;
+ --text-caption--line-height: 0.8125rem;
+
+ /* Font families */
+ --font-sans: 'Geist Sans', ui-sans-serif, system-ui, sans-serif;
+ --font-serif:
+ 'Noto Serif CJK SC', 'Noto Serif SC', var(--font-serif),
+ 'Source Han Serif SC', 'Source Han Serif', source-han-serif-sc, SongTi SC,
+ SimSum, 'Hiragino Sans GB', system-ui, -apple-system, Segoe UI, Roboto,
+ Helvetica, 'Microsoft YaHei', 'WenQuanYi Micro Hei', sans-serif;
+ --font-mono:
+ 'OperatorMonoSSmLig Nerd Font', 'Cascadia Code PL',
+ 'FantasqueSansMono Nerd Font', 'operator mono', JetBrainsMono,
+ 'Fira code Retina', 'Fira code', Consolas, Monaco, 'Hannotate SC',
+ monospace, -apple-system;
+
+ /* Custom screens */
+ --screen-light-mode: (prefers-color-scheme: light);
+ --screen-dark-mode: (prefers-color-scheme: dark);
+
+ /* Width and max-width */
+ --width-screen: 100vw;
+ --max-width-screen: 100vw;
+
+ /* Height and max-height */
+ --height-screen: 100vh;
+ --max-height-screen: 100vh;
+
+ --color-primary: var(--color-accent);
+ --color-primary-light: var(--color-accent-light);
+ --color-primary-dark: var(--color-accent-dark);
+}
+
+@layer theme {
+ #root {
+ --color-primary: var(--color-accent);
+ --color-primary-light: var(--color-accent-light);
+ --color-primary-dark: var(--color-accent-dark);
+ }
+}
+
+@layer base {
+ .container {
+ margin-left: auto;
+ margin-right: auto;
+ padding: var(--container-padding);
+ }
+ @media (min-width: 1536px) {
+ .container {
+ max-width: var(--container-max-width-2xl);
+ }
+ }
+}
+
+html {
+ @apply font-sans;
+}
+
+html body {
+ @apply max-w-screen overflow-x-hidden;
+}
+
+*:not(input):not(textarea):not([contenteditable='true']):focus-visible {
+ outline: 0 !important;
+}
+
+@font-face {
+ font-family: 'Geist Sans';
+ src: url('../assets/fonts/GeistVF.woff2') format('woff2');
+ font-style: normal;
+ font-weight: 100 200 300 400 500 600 700 800 900;
+}
+
+body {
+ font-feature-settings:
+ 'rlig' 1,
+ 'calt' 1;
+}
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ /* Use Pastel palette tokens; provide minimal shims for existing classes */
+ --color-ring: var(--color-accent);
+ --color-foreground: var(--color-text);
+ --color-muted-foreground: var(--color-text-secondary);
+}
+
+@layer theme {
+ :root {
+ @variant dark {
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+ }
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/be/apps/dashboard/src/vite-env.d.ts b/be/apps/dashboard/src/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/be/apps/dashboard/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/be/apps/dashboard/tsconfig.json b/be/apps/dashboard/tsconfig.json
new file mode 100644
index 00000000..249a4241
--- /dev/null
+++ b/be/apps/dashboard/tsconfig.json
@@ -0,0 +1,35 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "lib": [
+ "DOM",
+ "DOM.Iterable",
+ "ESNext"
+ ],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": false,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "skipDefaultLibCheck": true,
+ "noImplicitAny": false,
+ "noEmit": true,
+ "jsx": "preserve",
+ "paths": {
+ "~/*": [
+ "./src/*"
+ ],
+ "@pkg": [
+ "./package.json"
+ ]
+ },
+ },
+ "include": [
+ "./src/**/*",
+ ]
+}
\ No newline at end of file
diff --git a/be/apps/dashboard/vite.config.ts b/be/apps/dashboard/vite.config.ts
new file mode 100644
index 00000000..1c25bc41
--- /dev/null
+++ b/be/apps/dashboard/vite.config.ts
@@ -0,0 +1,38 @@
+import { fileURLToPath, resolve } from 'node:url'
+
+import tailwindcss from '@tailwindcss/vite'
+import reactRefresh from '@vitejs/plugin-react'
+import { codeInspectorPlugin } from 'code-inspector-plugin'
+import { defineConfig } from 'vite'
+import { checker } from 'vite-plugin-checker'
+import { routeBuilderPlugin } from 'vite-plugin-route-builder'
+import tsconfigPaths from 'vite-tsconfig-paths'
+
+import PKG from './package.json'
+
+const ROOT = fileURLToPath(new URL('./', import.meta.url))
+
+export default defineConfig({
+ plugins: [
+ reactRefresh(),
+ tsconfigPaths(),
+ checker({
+ typescript: true,
+ enableBuild: true,
+ }),
+ codeInspectorPlugin({
+ bundler: 'vite',
+ hotKeys: ['altKey'],
+ }),
+ tailwindcss(),
+ routeBuilderPlugin({
+ pagePattern: `${resolve(ROOT, './src/pages')}/**/*.tsx`,
+ outputPath: `${resolve(ROOT, './src/generated-routes.ts')}`,
+ enableInDev: true,
+ }),
+ ],
+ define: {
+ APP_DEV_CWD: JSON.stringify(process.cwd()),
+ APP_NAME: JSON.stringify(PKG.name),
+ },
+})
diff --git a/be/apps/demo/index.html b/be/apps/demo/index.html
new file mode 100644
index 00000000..b6491a7d
--- /dev/null
+++ b/be/apps/demo/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ WS Demo
+
+
+
+
+
+
diff --git a/be/apps/demo/package.json b/be/apps/demo/package.json
new file mode 100644
index 00000000..ee35785d
--- /dev/null
+++ b/be/apps/demo/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "core-demo",
+ "type": "module",
+ "version": "1.0.0",
+ "private": true,
+ "packageManager": "pnpm@10.18.0",
+ "scripts": {
+ "build": "tsc -b && vite build",
+ "dev": "vite",
+ "preview": "vite preview --port 5174"
+ },
+ "dependencies": {
+ "react": "19.2.0",
+ "react-dom": "19.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "19.2.2",
+ "@types/react-dom": "19.2.2",
+ "typescript": "5.9.3",
+ "vite": "7.1.12",
+ "vite-tsconfig-paths": "5.1.4"
+ }
+}
diff --git a/be/apps/demo/src/main.tsx b/be/apps/demo/src/main.tsx
new file mode 100644
index 00000000..cfe254b9
--- /dev/null
+++ b/be/apps/demo/src/main.tsx
@@ -0,0 +1,10 @@
+import * as React from 'react'
+import { createRoot } from 'react-dom/client'
+
+const container = document.querySelector('#root')!
+const root = createRoot(container)
+root.render(
+
+
+ ,
+)
diff --git a/be/apps/demo/tsconfig.json b/be/apps/demo/tsconfig.json
new file mode 100644
index 00000000..9dabdbce
--- /dev/null
+++ b/be/apps/demo/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "skipLibCheck": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "allowSyntheticDefaultImports": true,
+ "resolveJsonModule": true,
+ "types": []
+ },
+ "include": ["src", "vite.config.ts"],
+ "references": []
+}
diff --git a/be/apps/demo/vite.config.ts b/be/apps/demo/vite.config.ts
new file mode 100644
index 00000000..3ccb0b39
--- /dev/null
+++ b/be/apps/demo/vite.config.ts
@@ -0,0 +1,21 @@
+import { env } from 'node:process'
+
+import { defineConfig } from 'vite'
+import tsconfigPaths from 'vite-tsconfig-paths'
+
+const API_PORT = Number(env.CORE_PORT ?? 3000)
+const API_HOST = env.CORE_HOST ?? '0.0.0.0'
+
+export default defineConfig({
+ server: {
+ port: 5173,
+ host: true,
+ proxy: {
+ '/api': {
+ target: `http://${API_HOST}:${API_PORT}`,
+ changeOrigin: true,
+ },
+ },
+ },
+ plugins: [tsconfigPaths()],
+})
diff --git a/be/eslint.config.ts b/be/eslint.config.ts
new file mode 100644
index 00000000..1c55e1aa
--- /dev/null
+++ b/be/eslint.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from 'eslint-config-hyoban'
+
+export default defineConfig(
+ {
+ formatting: false,
+ },
+ {
+ languageOptions: {
+ parserOptions: {
+ emitDecoratorMetadata: true,
+ experimentalDecorators: true,
+ },
+ },
+ rules: {
+ 'unicorn/no-useless-undefined': 0,
+ '@typescript-eslint/no-unsafe-function-type': 0,
+ },
+ },
+)
diff --git a/be/package.json b/be/package.json
new file mode 100644
index 00000000..789baa58
--- /dev/null
+++ b/be/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@afilmory/be",
+ "type": "module",
+ "version": "1.0.0",
+ "private": true,
+ "author": "Innei",
+ "license": "MIT",
+ "main": "index.js",
+ "scripts": {
+ "db:generate": "pnpm --filter core db:generate",
+ "db:migrate": "pnpm --filter core db:migrate",
+ "db:studio": "pnpm --filter core db:studio",
+ "dev": "pnpm --filter core dev",
+ "lint": "eslint . --fix",
+ "prepare": "simple-git-hooks",
+ "test": "pnpm run -r test"
+ },
+ "dependencies": {
+ "prettier": "3.6.2"
+ },
+ "devDependencies": {
+ "eslint": "9.38.0",
+ "eslint-config-hyoban": "4.0.10",
+ "jiti": "2.6.1",
+ "lint-staged": "16.2.6",
+ "simple-git-hooks": "2.13.1",
+ "typescript": "catalog:"
+ },
+ "simple-git-hooks": {
+ "pre-commit": "npx lint-staged"
+ },
+ "lint-staged": {
+ "*": "eslint --fix",
+ "*.{js,jsx,ts,tsx,json,css,md}": "prettier --write"
+ }
+}
diff --git a/be/packages/db/.env b/be/packages/db/.env
new file mode 120000
index 00000000..c7360fb8
--- /dev/null
+++ b/be/packages/db/.env
@@ -0,0 +1 @@
+../../.env
\ No newline at end of file
diff --git a/be/packages/db/drizzle.config.ts b/be/packages/db/drizzle.config.ts
new file mode 100644
index 00000000..47b12d79
--- /dev/null
+++ b/be/packages/db/drizzle.config.ts
@@ -0,0 +1,11 @@
+import { env } from '@afilmory/env'
+import { defineConfig } from 'drizzle-kit'
+
+export default defineConfig({
+ schema: './src/schema.ts',
+ out: './migrations',
+ dialect: 'postgresql',
+ dbCredentials: {
+ url: env.DATABASE_URL,
+ },
+})
diff --git a/be/packages/db/migrations/meta/_journal.json b/be/packages/db/migrations/meta/_journal.json
new file mode 100644
index 00000000..a7e0211f
--- /dev/null
+++ b/be/packages/db/migrations/meta/_journal.json
@@ -0,0 +1,5 @@
+{
+ "version": "7",
+ "dialect": "postgresql",
+ "entries": []
+}
diff --git a/be/packages/db/package.json b/be/packages/db/package.json
new file mode 100644
index 00000000..47c1f384
--- /dev/null
+++ b/be/packages/db/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "@afilmory/db",
+ "type": "module",
+ "version": "1.0.0",
+ "exports": {
+ ".": {
+ "import": "./src/index.ts",
+ "types": "./src/index.ts"
+ }
+ },
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "scripts": {
+ "db:generate": "drizzle-kit generate",
+ "db:migrate": "drizzle-kit migrate",
+ "db:studio": "drizzle-kit studio"
+ },
+ "dependencies": {
+ "@afilmory/utils": "workspace:*",
+ "drizzle-orm": "^0.44.7",
+ "pg": "^8.16.3",
+ "zod": "^4.1.11"
+ },
+ "devDependencies": {
+ "@afilmory/env": "workspace:*",
+ "drizzle-kit": "0.31.5"
+ }
+}
diff --git a/be/packages/db/src/index.ts b/be/packages/db/src/index.ts
new file mode 100644
index 00000000..17459d8b
--- /dev/null
+++ b/be/packages/db/src/index.ts
@@ -0,0 +1,2 @@
+export * from './schema'
+export * from './types'
diff --git a/be/packages/db/src/schema.ts b/be/packages/db/src/schema.ts
new file mode 100644
index 00000000..8578e407
--- /dev/null
+++ b/be/packages/db/src/schema.ts
@@ -0,0 +1,126 @@
+import { generateId } from '@afilmory/be-utils'
+import { boolean, pgEnum, pgTable, text, timestamp, unique } from 'drizzle-orm/pg-core'
+
+function createSnowflakeId(name: string) {
+ return text(name).$defaultFn(() => generateId())
+}
+const snowflakeId = createSnowflakeId('id').primaryKey()
+
+// =========================
+// Better Auth custom schema
+// =========================
+
+export const userRoleEnum = pgEnum('user_role', ['user', 'admin', 'superadmin'])
+
+export const tenantStatusEnum = pgEnum('tenant_status', ['active', 'inactive', 'suspended'])
+
+export const tenants = pgTable(
+ 'tenant',
+ {
+ id: snowflakeId,
+ slug: text('slug').notNull(),
+ name: text('name').notNull(),
+ status: tenantStatusEnum('status').notNull().default('inactive'),
+ primaryDomain: text('primary_domain'),
+ createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
+ updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
+ },
+ (t) => [unique('uq_tenant_slug').on(t.slug)],
+)
+
+export const tenantDomains = pgTable(
+ 'tenant_domain',
+ {
+ id: snowflakeId,
+ tenantId: text('tenant_id')
+ .notNull()
+ .references(() => tenants.id, { onDelete: 'cascade' }),
+ domain: text('domain').notNull(),
+ isPrimary: boolean('is_primary').notNull().default(false),
+ createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
+ updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
+ },
+ (t) => [unique('uq_tenant_domain_domain').on(t.domain)],
+)
+
+// Custom users table (Better Auth: user)
+export const authUsers = pgTable('auth_user', {
+ id: text('id').primaryKey(),
+ name: text('name').notNull(),
+ email: text('email').notNull().unique(),
+ emailVerified: boolean('email_verified').default(false).notNull(),
+ image: text('image'),
+ role: userRoleEnum('role').notNull().default('user'),
+ tenantId: text('tenant_id').references(() => tenants.id, { onDelete: 'set null' }),
+ createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
+ updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
+ twoFactorEnabled: boolean('two_factor_enabled').default(false).notNull(),
+ username: text('username'),
+ displayUsername: text('display_username'),
+ banned: boolean('banned').default(false).notNull(),
+ banReason: text('ban_reason'),
+ banExpires: timestamp('ban_expires_at', { mode: 'string' }),
+})
+
+// Custom sessions table (Better Auth: session)
+export const authSessions = pgTable('auth_session', {
+ id: text('id').primaryKey(),
+ expiresAt: timestamp('expires_at', { mode: 'string' }).notNull(),
+ token: text('token').notNull().unique(),
+ createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
+ updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
+ ipAddress: text('ip_address'),
+ userAgent: text('user_agent'),
+ tenantId: text('tenant_id').references(() => tenants.id, { onDelete: 'set null' }),
+ userId: text('user_id')
+ .notNull()
+ .references(() => authUsers.id, { onDelete: 'cascade' }),
+})
+
+// Custom accounts table (Better Auth: account)
+export const authAccounts = pgTable('auth_account', {
+ id: text('id').primaryKey(),
+ accountId: text('account_id').notNull(),
+ providerId: text('provider_id').notNull(),
+ userId: text('user_id')
+ .notNull()
+ .references(() => authUsers.id, { onDelete: 'cascade' }),
+ accessToken: text('access_token'),
+ refreshToken: text('refresh_token'),
+ idToken: text('id_token'),
+ accessTokenExpiresAt: timestamp('access_token_expires_at', { mode: 'string' }),
+ refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { mode: 'string' }),
+ scope: text('scope'),
+ password: text('password'),
+ createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
+ updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
+})
+
+export const settings = pgTable(
+ 'settings',
+ {
+ id: snowflakeId,
+
+ tenantId: text('tenant_id')
+ .notNull()
+ .references(() => tenants.id, { onDelete: 'cascade' }),
+ key: text('key').notNull(),
+ value: text('value').notNull(),
+
+ isSensitive: boolean('is_sensitive').notNull().default(false),
+ createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
+ updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
+ },
+ (t) => [unique('uq_settings_tenant_key').on(t.tenantId, t.key)],
+)
+
+export const dbSchema = {
+ tenants,
+ tenantDomains,
+ authUsers,
+ authSessions,
+ authAccounts,
+ settings,
+}
+
+export type DBSchema = typeof dbSchema
diff --git a/be/packages/db/src/types.ts b/be/packages/db/src/types.ts
new file mode 100644
index 00000000..529795f5
--- /dev/null
+++ b/be/packages/db/src/types.ts
@@ -0,0 +1,9 @@
+import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
+
+import type { DBSchema } from './schema'
+
+export type Drizzle = NodePgDatabase
+
+export type UUID = string & { readonly __brand: 'uuid' }
+
+export type TimestampISO = string & { readonly __brand: 'iso_timestamp' }
diff --git a/be/packages/env/package.json b/be/packages/env/package.json
new file mode 100644
index 00000000..a0dd37f5
--- /dev/null
+++ b/be/packages/env/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@afilmory/env",
+ "type": "module",
+ "version": "1.0.0",
+ "exports": {
+ ".": {
+ "import": "./src/index.ts",
+ "types": "./src/index.ts"
+ }
+ },
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "dependencies": {
+ "@t3-oss/env-core": "^0.13.8",
+ "dotenv": "17.2.3",
+ "zod": "^4.1.11"
+ }
+}
diff --git a/be/packages/env/src/index.ts b/be/packages/env/src/index.ts
new file mode 100644
index 00000000..0f7e7668
--- /dev/null
+++ b/be/packages/env/src/index.ts
@@ -0,0 +1,40 @@
+import 'dotenv/config'
+
+import { createEnv } from '@t3-oss/env-core'
+import { z } from 'zod'
+
+export const env = createEnv({
+ server: {
+ NODE_ENV: z.enum(['development', 'test', 'production']).default(process.env.NODE_ENV as any),
+ PORT: z.string().regex(/^\d+$/).transform(Number).default(3000),
+ WS_PORT: z.string().regex(/^\d+$/).transform(Number).default(3001),
+ HOSTNAME: z.string().default('0.0.0.0'),
+ API_KEY: z.string().min(1).optional(),
+ DATABASE_URL: z.url(),
+ REDIS_URL: z.url(),
+ PG_POOL_MAX: z.string().regex(/^\d+$/).transform(Number).optional(),
+ PG_IDLE_TIMEOUT: z.string().regex(/^\d+$/).transform(Number).optional(),
+ PG_CONN_TIMEOUT: z.string().regex(/^\d+$/).transform(Number).optional(),
+ // Optional social provider credentials for Better Auth
+ GOOGLE_CLIENT_ID: z.string().optional(),
+ GOOGLE_CLIENT_SECRET: z.string().optional(),
+ GITHUB_CLIENT_ID: z.string().optional(),
+ GITHUB_CLIENT_SECRET: z.string().optional(),
+
+ CONFIG_ENCRYPTION_KEY: z.string().min(1),
+ DEFAULT_TENANT_SLUG: z.string().min(1).default('default'),
+ DEFAULT_SUPERADMIN_EMAIL: z.string().email().default('root@local.host'),
+ DEFAULT_SUPERADMIN_USERNAME: z
+ .string()
+ .min(1)
+ .regex(/^[\w-]+$/)
+ .default('root'),
+
+ // INTERNAL
+ TEST: z.any().default(false),
+ },
+ runtimeEnv: process.env,
+ emptyStringAsUndefined: true,
+})
+
+export type NodeEnv = (typeof env)['NODE_ENV']
diff --git a/be/packages/framework/README.md b/be/packages/framework/README.md
new file mode 100644
index 00000000..c39bad22
--- /dev/null
+++ b/be/packages/framework/README.md
@@ -0,0 +1,390 @@
+
+
+# @afilmory/framework
+
+A lightweight yet feature-complete enterprise framework built on Hono, providing NestJS-like modularity, decorators, and dependency injection while retaining Hono's performance and flexibility.
+
+
+
+## 📚 Table of Contents
+
+- [Framework Positioning & Features](#framework-positioning--features)
+- [Quick Start](#quick-start)
+- [Framework Architecture](#framework-architecture)
+ - [Module System](#module-system)
+ - [Dependency Injection & Provider Lifecycle](#dependency-injection--provider-lifecycle)
+ - [Controllers & Route Mapping](#controllers--route-mapping)
+ - [Enhancer System: Guards, Pipes, Interceptors, Exception Filters](#enhancer-system-guards-pipes-interceptors-exception-filters)
+ - [Middleware System](#middleware-system)
+ - [Request Context HttpContext](#request-context-httpcontext)
+ - [Validation & DTOs](#validation--dtos)
+ - [Logging System](#logging-system)
+ - [OpenAPI Document Generation](#openapi-document-generation)
+ - [Event System](#event-system)
+- [Request Execution Flow](#request-execution-flow)
+- [Lifecycle Hooks & Application Management](#lifecycle-hooks--application-management)
+- [Testing Strategy](#testing-strategy)
+- [Best Practices & Common Pitfalls](#best-practices--common-pitfalls)
+- [Reference Implementation & Examples](#reference-implementation--examples)
+
+---
+
+## Framework Positioning & Features
+
+`@afilmory/framework` is a server-side framework built around Hono, aimed at providing an enterprise-grade development experience while maintaining performance:
+
+- **Decorator-Driven**: Modules, controllers, routes, parameters, and enhancers are all declared using decorators.
+- **Dependency Injection**: Container based on `tsyringe`, supporting singleton/factory/provider configurations with strict checking for unregistered dependencies.
+- **Request-Scoped Context**: Implemented using `AsyncLocalStorage`, enabling access to Hono `Context` and custom values at any level.
+- **Enhancer System**: Guards, Pipes, Interceptors, and Exception Filters work in layers, applicable globally/controller-level/method-level.
+- **Type-Safe Validation**: Strong typing for requests through Zod schemas + DTO generators.
+- **Modern Logging**: `PrettyLogger` provides namespaces, colored output, and level control.
+- **OpenAPI Support**: Automatically collects decorator metadata to generate OpenAPI 3.1 specification documents.
+- **Event-Driven Extension**: Built-in Redis-driven event system supporting cross-process pub/sub.
+
+## Quick Start
+
+```ts
+// main.ts
+import 'reflect-metadata'
+import { serve } from '@hono/node-server'
+import { createApplication } from '@afilmory/framework'
+import { AppModule } from './app.module'
+
+async function bootstrap() {
+ const app = await createApplication(AppModule, {
+ globalPrefix: '/api',
+ })
+
+ // Register global enhancers (optional) — pass INSTANCES
+ // app.useGlobalGuards(new AuthGuard())
+ // app.useGlobalPipes(new ValidationPipe())
+ // Or use APP_* tokens in module providers (see below)
+
+ const hono = app.getInstance()
+ serve({ fetch: hono.fetch, port: 3000 })
+}
+
+bootstrap()
+```
+
+```ts
+// app.module.ts
+import { Module, Controller, Get } from '@afilmory/framework'
+
+@Controller('hello')
+class HelloController {
+ @Get('/')
+ sayHi() {
+ return { message: 'Hello afilmory!' }
+ }
+}
+
+@Module({ controllers: [HelloController] })
+export class AppModule {}
+```
+
+## Framework Architecture
+
+### Module System
+
+- Declare modules using the `@Module` decorator, organizing business logic in three parts: `imports`, `controllers`, and `providers`.
+- Supports `forwardRef(() => OtherModule)` to resolve circular dependencies.
+- Modules are initialized only once (singleton) and maintain their own provider collection.
+- Controller instantiation is delayed until route registration; Providers can be instantiated early before lifecycle hooks.
+
+```ts
+@Module({
+ imports: [DatabaseModule],
+ controllers: [UserController],
+ providers: [UserService],
+})
+export class UserModule {}
+```
+
+### Dependency Injection & Provider Lifecycle
+
+- Container based on `tsyringe`; framework creates an independent container instance for the root module at startup and stores it in the global `ContainerRef`.
+- Provider support:
+ - Direct class registration (singleton by default).
+ - `useClass` / `useValue` / `useExisting` / `useFactory`.
+ - `singleton: false` can declare non-singleton providers.
+- **Strict DI with smart exceptions**:
+ - Container is patched: attempting to resolve unregistered tokens throws a `ReferenceError`, helping quickly locate DI configuration issues.
+ - **Exception**: Classes referenced in enhancer decorators (`@UseGuards`, `@UsePipes`, `@UseInterceptors`, `@UseFilters`) are auto-registered as singletons on first use, so they don't need to be listed in `providers` unless they have dependencies.
+- Framework automatically detects Providers implementing lifecycle interfaces and invokes their hooks at appropriate times.
+
+```ts
+@injectable()
+export class UserService implements OnModuleInit, OnModuleDestroy {
+ async onModuleInit() {}
+ async onModuleDestroy() {}
+}
+```
+
+### Controllers & Route Mapping
+
+- `@Controller(prefix)` specifies the base path; HTTP method decorators (`@Get`, `@Post`, etc.) declare routes.
+- Framework reads controller metadata and registers corresponding handlers on the Hono instance.
+- Route parameters are injected via parameter decorators (`@Param`, `@Query`, `@Body`, `@Headers`, `@Req`, `@ContextParam`).
+- Parameters without decorators are automatically inferred as `Context`, allowing direct access.
+
+### Enhancer System: Guards, Pipes, Interceptors, Exception Filters
+
+- `@UseGuards` / `@UsePipes` / `@UseInterceptors` / `@UseFilters` provide a unified interface, supporting both class-level and method-level stacking.
+- **Auto-registration of decorator classes**: Class references in decorators (e.g., `@UseGuards(AuthGuard)`) are automatically registered as singletons on first use. You don't need to list them in `providers` if they have no extra dependencies. If they depend on other services, those dependencies must still be registered in a module.
+- **Global enhancers** can be registered in two ways:
+ 1. **Instance-based** via `app.useGlobal*()` — pass instances only:
+ ```ts
+ app.useGlobalGuards(new AuthGuard())
+ app.useGlobalPipes(new ValidationPipe())
+ app.useGlobalInterceptors(new LoggingInterceptor())
+ app.useGlobalFilters(new AllExceptionsFilter())
+ app.useGlobalMiddlewares({
+ handler: new RequestTracingMiddleware(),
+ path: ['/api/*', /auth/],
+ priority: -10,
+ })
+ ```
+ 2. **Module-based** via `APP_*` tokens in providers (NestJS-style):
+
+ ```ts
+ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, APP_PIPE } from '@afilmory/framework'
+
+ @Module({
+ providers: [
+ { provide: APP_GUARD, useClass: AuthGuard },
+ { provide: APP_PIPE, useValue: preconfiguredPipe },
+ { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
+ { provide: APP_INTERCEPTOR, useClass: ResponseTransformInterceptor },
+ { provide: APP_FILTER, useFactory: (...deps) => new CustomFilter(...deps), inject: [Logger] },
+ { provide: APP_MIDDLEWARE, useClass: RequestTracingMiddleware },
+ ],
+ })
+ export class AppModule {}
+ ```
+
+ These are materialized during `init()` before controllers are bound.
+
+- Execution order:
+ 1. Global guards → Controller guards → Method guards.
+ 2. Interceptors wrap handlers in a stack, supporting pre/post-request processing (logging, caching, response wrapping, etc.).
+ 3. Pipes act on input parameters, enabling DTO validation and transformation with Zod engine.
+ 4. Exception filters handle errors and return custom Responses.
+
+### Middleware System
+
+- Middlewares implement the `HttpMiddleware` interface and expose an async `use(context, next)` method. Decorate classes with `@Middleware({ path, priority })` to attach optional routing metadata.
+- When no metadata is provided, the framework defaults to `path: '/*'` and `priority: 0`. Lower priority values run earlier; definitions are sorted before registration so fallback values still participate in ordering.
+- Metadata from `@Middleware` and `MiddlewareDefinition` objects is merged—explicit properties win, decorator values fill the gaps. Supported paths include strings, regular expressions, or arrays of both.
+- Registration options:
+ - Call `app.useGlobalMiddlewares()` with one or more `MiddlewareDefinition` objects. Each definition must include a `handler` instance; `path` and `priority` remain optional.
+ - Provide middlewares through the dependency-injected `APP_MIDDLEWARE` token using `useClass`, `useExisting`, `useValue`, or `useFactory`. Factories may return either a `HttpMiddleware` instance or a full `MiddlewareDefinition`.
+- Middleware handlers participate in lifecycle hooks (`OnModuleInit`, `OnModuleDestroy`, etc.) just like other providers.
+
+```ts
+import type { Context, Next } from 'hono'
+import { APP_MIDDLEWARE, Middleware } from '@afilmory/framework'
+import { injectable } from 'tsyringe'
+
+// Logger, CacheService, and LegacyMiddleware are regular injectables.
+
+@Middleware({ path: ['/api/*', /reports/], priority: -20 })
+@injectable()
+class RequestTracingMiddleware {
+ constructor(private readonly logger: Logger) {}
+
+ async use(context: Context, next: Next) {
+ const start = Date.now()
+ await next()
+ this.logger.info('request', {
+ path: context.req.path,
+ durationMs: Date.now() - start,
+ })
+ }
+}
+
+@Module({
+ providers: [
+ Logger,
+ { provide: APP_MIDDLEWARE, useClass: RequestTracingMiddleware },
+ {
+ provide: APP_MIDDLEWARE,
+ useFactory: (cache: CacheService) => ({
+ handler: {
+ async use(context: Context, next: Next) {
+ await next()
+ cache.touch(context.req.path)
+ },
+ },
+ path: '/static/*',
+ priority: 10,
+ }),
+ inject: [CacheService],
+ },
+ ],
+})
+export class AppModule {}
+
+// Or register imperatively after bootstrap
+app.useGlobalMiddlewares({ handler: new LegacyMiddleware(), path: '/legacy' })
+```
+
+### Request Context HttpContext
+
+- Provides request-scoped data container via `AsyncLocalStorage`, with the current Hono `Context` injected by default.
+- Within any Provider/guard/interceptor, call `HttpContext.get()` or `HttpContext.getValue('hono')` to retrieve context.
+- Supports `HttpContext.assign({ userId })` to extend custom values, with type augmentation via module declaration.
+
+```ts
+declare module '@afilmory/framework' {
+ interface HttpContextValues {
+ userId?: string
+ }
+}
+
+HttpContext.assign({ userId: 'u_123' })
+```
+
+### Validation & DTOs
+
+- `createZodSchemaDto` / `createZodDto` helper functions: automatically generate DTO classes from Zod Schemas and write metadata.
+- `ZodValidationPipe`:
+ - Transforms to DTO instances by default.
+ - Supports strategies like whitelist / forbidUnknownValues / stopAtFirstError.
+ - Validation failures throw `HttpException` with structured `errors`.
+- Pre-configured pipes can be generated using `createZodValidationPipe(options)`.
+
+```ts
+const CreateUserSchema = z.object({ email: z.string().email() })
+class CreateUserDto extends createZodSchemaDto(CreateUserSchema) {}
+
+@Post('/')
+async create(@Body(CreateUserValidationPipe) dto: CreateUserDto) {
+ return this.service.create(dto)
+}
+```
+
+### Logging System
+
+- `PrettyLogger` supports:
+ - Namespace levels (`logger.extend('Router')`).
+ - Colored output, symbol/text labels, timestamps.
+ - Minimum log level (can be auto-downgraded to debug via `DEBUG=true`).
+- Framework prints debug logs for DI, routing, lifecycle, etc. at critical stages.
+
+### OpenAPI Document Generation
+
+- `createOpenApiDocument(rootModule, options)` collects module → controller → route hierarchy information.
+- Automatically recognizes:
+ - Paths, HTTP methods, OperationIds.
+ - Parameter locations (path/query/header) and request body schemas.
+ - Zod Schema → JSON Schema conversion (supports optional/nullable, enums, unions, nested structures).
+ - Custom `@ApiTags`, `@ApiDoc` metadata.
+- Outputs complete OpenAPI 3.1 compliant documentation, including component schemas and module topology (`x-modules`).
+
+### Event System
+
+- Located in `src/events`, provides:
+ - `@OnEvent(event)` annotation marks methods as consumers.
+ - `@EmitEvent(event)` automatically publishes events after method success.
+ - `EventEmitterService` uses Redis Pub/Sub for cross-instance broadcasting.
+ - `EventModule.forRootAsync({ useFactory })` supports async initialization of Redis Client.
+- Runtime scans container for listeners and auto-binds them; releases subscriptions on graceful shutdown.
+
+## Request Execution Flow
+
+```text
+Inbound Request
+ └─> HttpContext.run() establishes request context
+ ├─ Guards (global → controller → method)
+ ├─ Interceptors (forward pre-logic)
+ ├─ Parameter resolution & pipe transformation
+ ├─ Controller handler
+ ├─ Interceptors (reverse post-logic)
+ ├─ Exception filters (if error thrown)
+ └─ Normalized Response output
+```
+
+The framework preserves the original `context.res` when no explicit return, or automatically wraps objects/strings into Response with marking to avoid duplicate serialization.
+
+## Lifecycle Hooks & Application Management
+
+Providers implementing the following interfaces are automatically registered:
+
+- `OnModuleInit` / `OnModuleDestroy`
+- `OnApplicationBootstrap`
+- `BeforeApplicationShutdown`
+- `OnApplicationShutdown`
+
+`HonoHttpApplication` during `init()`:
+
+1. Recursively registers modules and Providers.
+2. Collects lifecycle participants and instantiates on demand.
+3. Registers controllers and routes.
+4. Invokes `onApplicationBootstrap()`.
+
+`close(signal?)` is used for graceful shutdown, triggering in order: `before → moduleDestroy → applicationShutdown`.
+
+## Testing Strategy
+
+**Global enhancers:**
+
+`useGlobal*` methods accept instances created with `new` (not resolved from container). For middlewares, wrap the instance in a `MiddlewareDefinition`:
+
+```ts
+const app = await createApplication(AppModule)
+
+// Pass instances created with 'new'
+app.useGlobalGuards(new AuthGuard())
+app.useGlobalPipes(new ValidationPipe())
+app.useGlobalInterceptors(new LoggingInterceptor())
+app.useGlobalFilters(new AllExceptionsFilter())
+app.useGlobalMiddlewares({ handler: new LegacyMiddleware(), path: '/*' })
+```
+
+**If your enhancer needs DI**, prefer using `APP_*` tokens in module providers instead:
+
+```ts
+import { APP_GUARD, APP_INTERCEPTOR } from '@afilmory/framework'
+
+@Module({
+ providers: [
+ { provide: APP_GUARD, useClass: AuthGuard },
+ { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
+ ],
+})
+export class AppModule {}
+```
+
+**Testing approaches:**
+
+- Recommended to use `vitest` (already configured in repository).
+- **Unit tests**: Leverage `tsyringe` container to inject mocks and directly resolve Service instances.
+ ```ts
+ const container = app.getContainer()
+ const service = container.resolve(UserService)
+ ```
+- **Integration tests**: Call `createApplication()` to construct complete app, use `app.getInstance().request()` to make requests, testing interceptors, pipes, and filters.
+- **Event system**: Can be verified by replacing Redis Client (e.g., in-memory mock).
+
+## Best Practices & Common Pitfalls
+
+- **Avoid type-only imports**: DI resolution requires actual classes. Use `import { Service } from './service'`, not `import type { Service }`.
+- **Decorator auto-registration**: Classes in `@UseGuards(Guard)`, `@UsePipes(Pipe)`, etc. are auto-registered as singletons. If they have dependencies, those dependencies must be registered in a module.
+- **Global enhancers**: `app.useGlobal*()` accepts instances created with `new` (not from container). If your enhancers need DI, use `APP_*` tokens in module providers instead—this is preferred for cleaner organization and proper dependency injection.
+- **Provider ordering**: If relying on lifecycle hooks, ensure the containing module is correctly imported via `imports`.
+- **Request body JSON validation**: Framework validates `content-type: application/json` by default; other types require custom pipes.
+- **Context access**: `HttpContext` is only valid within requests; manual context injection needed in task queues or event callbacks.
+- **OpenAPI DTO naming**: Schemas use class names by default, customizable via `createZodSchemaDto(schema, { name })`.
+- **Logging & DEBUG**: Setting `DEBUG=true` enables verbose DI/routing logs; production environments should keep default levels.
+
+## Reference Implementation & Examples
+
+- `packages/core`: Demonstrates framework organization in real services (modules, guards, filters).
+- `packages/task-queue`, `packages/websocket`: Illustrate how to write extension modules and cross-package integration.
+- `packages/framework/tests`: Covers DI, decorator metadata, route execution, and exception handling scenarios—recommended starting point.
+
+---
+
+For more details, consult `AGENTS.md` in the repository root (detailed development guide for AI agents) or read the `src/` directory source code directly. Feel free to extend modules, enhancers, and tooling based on business needs.
diff --git a/be/packages/framework/package.json b/be/packages/framework/package.json
new file mode 100644
index 00000000..62f49fd1
--- /dev/null
+++ b/be/packages/framework/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@afilmory/framework",
+ "type": "module",
+ "version": "1.0.0",
+ "exports": {
+ ".": {
+ "import": "./src/index.ts",
+ "types": "./src/index.ts"
+ }
+ },
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "scripts": {
+ "test": "vitest run",
+ "test:coverage": "vitest run --coverage",
+ "test:watch": "vitest"
+ },
+ "peerDependencies": {
+ "ioredis": ">=5.8.0"
+ },
+ "dependencies": {
+ "hono": "4.10.2",
+ "picocolors": "1.1.1",
+ "reflect-metadata": "0.2.2",
+ "tsyringe": "4.10.0",
+ "zod": "^4.1.11"
+ },
+ "devDependencies": {
+ "@types/node": "^24.9.1",
+ "@vitest/coverage-v8": "4.0.3",
+ "ioredis": "5.8.2",
+ "unplugin-swc": "1.5.8",
+ "vitest": "4.0.3"
+ }
+}
diff --git a/be/packages/framework/src/application.ts b/be/packages/framework/src/application.ts
new file mode 100644
index 00000000..14b059b3
--- /dev/null
+++ b/be/packages/framework/src/application.ts
@@ -0,0 +1,1258 @@
+import 'reflect-metadata'
+
+import type { Context, Next } from 'hono'
+import { Hono } from 'hono'
+import colors from 'picocolors'
+import type { ClassProvider, DependencyContainer, InjectionToken, TokenProvider, ValueProvider } from 'tsyringe'
+import { container as rootContainer } from 'tsyringe'
+
+import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, APP_PIPE, isDebugEnabled } from './constants'
+import { HttpContext } from './context/http-context'
+import { getControllerMetadata } from './decorators/controller'
+import { getRoutesMetadata } from './decorators/http-methods'
+import { getMiddlewareMetadata } from './decorators/middleware'
+import { getModuleMetadata, resolveModuleImports } from './decorators/module'
+import { getRouteArgsMetadata } from './decorators/params'
+import { BadRequestException, ForbiddenException, HttpException } from './http-exception'
+import type {
+ ArgumentMetadata,
+ BeforeApplicationShutdown,
+ CallHandler,
+ CanActivate,
+ Constructor,
+ ExceptionFilter,
+ ExistingProvider,
+ FrameworkResponse,
+ GlobalEnhancerRegistry,
+ HttpMiddleware,
+ Interceptor,
+ MiddlewareDefinition,
+ MiddlewareMetadata,
+ MiddlewarePath,
+ OnApplicationBootstrap,
+ OnApplicationShutdown,
+ OnModuleDestroy,
+ OnModuleInit,
+ PipeTransform,
+ RouteParamMetadataItem,
+} from './interfaces'
+import { RouteParamtypes } from './interfaces'
+import type { PrettyLogger } from './logger'
+import { createLogger } from './logger'
+import { ContainerRef } from './utils/container-ref'
+import { createExecutionContext } from './utils/execution-context'
+import { collectFilters, collectGuards, collectInterceptors, collectPipes } from './utils/metadata'
+
+type ProviderConfig =
+ | Constructor
+ | ClassProvider
+ | ValueProvider
+ | TokenProvider
+ | ExistingProvider
+ | {
+ provide: InjectionToken
+ useFactory: (...args: any[]) => unknown
+ inject?: InjectionToken[]
+ singleton?: boolean
+ }
+
+const GENERATED_RESPONSE = Symbol.for('hono.framework.generatedResponse')
+
+export interface ApplicationOptions {
+ container?: DependencyContainer
+ globalPrefix?: string
+ logger?: PrettyLogger
+}
+
+function createDefaultRegistry(): GlobalEnhancerRegistry {
+ return {
+ guards: [],
+ pipes: [],
+ interceptors: [],
+ filters: [],
+ middlewares: [],
+ }
+}
+
+type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'
+
+function isOnModuleInitHook(value: unknown): value is OnModuleInit {
+ return typeof value === 'object' && value !== null && typeof (value as OnModuleInit).onModuleInit === 'function'
+}
+
+function isOnModuleDestroyHook(value: unknown): value is OnModuleDestroy {
+ return typeof value === 'object' && value !== null && typeof (value as OnModuleDestroy).onModuleDestroy === 'function'
+}
+
+function isOnApplicationBootstrapHook(value: unknown): value is OnApplicationBootstrap {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ typeof (value as OnApplicationBootstrap).onApplicationBootstrap === 'function'
+ )
+}
+
+function isBeforeApplicationShutdownHook(value: unknown): value is BeforeApplicationShutdown {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ typeof (value as BeforeApplicationShutdown).beforeApplicationShutdown === 'function'
+ )
+}
+
+function isOnApplicationShutdownHook(value: unknown): value is OnApplicationShutdown {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ typeof (value as OnApplicationShutdown).onApplicationShutdown === 'function'
+ )
+}
+
+export class HonoHttpApplication {
+ private readonly app = new Hono()
+ private readonly container: DependencyContainer
+ private readonly globalEnhancers: GlobalEnhancerRegistry = createDefaultRegistry()
+ private readonly registeredModules = new Set()
+ private readonly logger: PrettyLogger
+ private readonly diLogger: PrettyLogger
+ private readonly routerLogger: PrettyLogger
+ private readonly middlewareLogger: PrettyLogger
+ private readonly moduleName: string
+ private readonly instances = new Map()
+ private readonly moduleInitCalled = new Set()
+ private readonly moduleDestroyHooks: OnModuleDestroy[] = []
+ private readonly applicationBootstrapHooks: OnApplicationBootstrap[] = []
+ private readonly beforeApplicationShutdownHooks: BeforeApplicationShutdown[] = []
+ private readonly applicationShutdownHooks: OnApplicationShutdown[] = []
+ private applicationBootstrapInvoked = false
+ private isInitialized = false
+ private isClosing = false
+ private readonly pendingControllers: Constructor[] = []
+ private readonly pendingLifecycleTokens: Constructor[] = []
+ private readonly pendingGlobalGuardResolvers: Array<() => CanActivate> = []
+ private readonly pendingGlobalPipeResolvers: Array<() => PipeTransform> = []
+ private readonly pendingGlobalInterceptorResolvers: Array<() => Interceptor> = []
+ private readonly pendingGlobalFilterResolvers: Array<() => ExceptionFilter> = []
+ private readonly pendingGlobalMiddlewareResolvers: Array<() => MiddlewareDefinition> = []
+
+ constructor(
+ private readonly rootModule: Constructor,
+ private readonly options: ApplicationOptions = {},
+ ) {
+ this.logger = options.logger ?? createLogger('Framework')
+ this.diLogger = this.logger.extend('DI')
+ this.routerLogger = this.logger.extend('Router')
+ this.middlewareLogger = this.logger.extend('Middleware')
+ const rawModuleName = (this.rootModule as Function).name
+ this.moduleName = rawModuleName && rawModuleName.trim().length > 0 ? rawModuleName : 'AnonymousModule'
+ this.container = options.container ?? rootContainer.createChildContainer()
+ ContainerRef.set(this.container)
+ this.logger.info(
+ `Initialized application container for module ${this.moduleName}`,
+ colors.green(`+${performance.now().toFixed(2)}ms`),
+ )
+ }
+
+ async init(): Promise {
+ this.logger.verbose(`Bootstrapping application for module ${this.moduleName}`)
+ await this.registerModule(this.rootModule)
+
+ // First, instantiate providers that participate in lifecycle hooks
+ if (this.pendingLifecycleTokens.length > 0) {
+ await this.invokeModuleInit(this.pendingLifecycleTokens)
+ }
+
+ // Materialize global enhancers provided via APP_* tokens
+ if (this.pendingGlobalGuardResolvers.length > 0) {
+ this.useGlobalGuards(...this.pendingGlobalGuardResolvers.map((r) => r()))
+ }
+ if (this.pendingGlobalPipeResolvers.length > 0) {
+ this.useGlobalPipes(...this.pendingGlobalPipeResolvers.map((r) => r()))
+ }
+ if (this.pendingGlobalInterceptorResolvers.length > 0) {
+ this.useGlobalInterceptors(...this.pendingGlobalInterceptorResolvers.map((r) => r()))
+ }
+ if (this.pendingGlobalFilterResolvers.length > 0) {
+ this.useGlobalFilters(...this.pendingGlobalFilterResolvers.map((r) => r()))
+ }
+ if (this.pendingGlobalMiddlewareResolvers.length > 0) {
+ this.useGlobalMiddlewares(...this.pendingGlobalMiddlewareResolvers.map((r) => r()))
+ }
+
+ // Then, register controllers (instantiates them and maps routes)
+ for (const controller of this.pendingControllers) {
+ this.registerController(controller)
+ }
+
+ await this.callApplicationBootstrapHooks()
+ this.isInitialized = true
+ this.logger.info(
+ `Application initialization complete for module ${this.moduleName}`,
+ colors.green(`+${performance.now().toFixed(2)}ms`),
+ )
+ }
+
+ getInitialized(): boolean {
+ return this.isInitialized
+ }
+
+ getInstance(): Hono {
+ return this.app
+ }
+
+ getContainer(): DependencyContainer {
+ return this.container
+ }
+
+ async close(signal?: string): Promise {
+ if (this.isClosing) {
+ return
+ }
+
+ this.isClosing = true
+ await this.callBeforeApplicationShutdownHooks(signal)
+ await this.callModuleDestroyHooks()
+ await this.callApplicationShutdownHooks(signal)
+ }
+
+ private getProviderInstance(token: Constructor): T {
+ if (!token) {
+ throw new ReferenceError('Cannot resolve provider for undefined token')
+ }
+
+ if (!this.instances.has(token)) {
+ let instance: T
+ try {
+ instance = this.resolveInstance(token)
+ } catch (error) {
+ if (error instanceof Error) {
+ const tokenName = this.formatTokenName(token)
+ error.message = `Failed to resolve provider ${tokenName}: ${error.message}`
+ }
+ throw error
+ }
+ this.instances.set(token, instance)
+ this.registerLifecycleHandlers(instance)
+ }
+
+ return this.instances.get(token) as T
+ }
+
+ private formatTokenName(token: Constructor | InjectionToken | undefined): string {
+ if (typeof token === 'function') {
+ if (token.name && token.name.length > 0) {
+ return token.name
+ }
+ return token.toString()
+ }
+
+ return String(token ?? 'AnonymousProvider')
+ }
+
+ private isConstructorToken(token: unknown): token is Constructor {
+ return typeof token === 'function'
+ }
+
+ /**
+ * Register a provider (Constructor or provider configuration object)
+ */
+ private registerProvider(provider: ProviderConfig, scopedTokens: Constructor[]): void {
+ // Simple case: Constructor as provider
+ if (this.isConstructorToken(provider)) {
+ this.registerSingleton(provider as Constructor)
+ scopedTokens.push(provider as Constructor)
+ return
+ }
+
+ // Complex case: Provider configuration object
+ const config = provider as any
+
+ if (!config || typeof config !== 'object' || !('provide' in config)) {
+ throw new Error(
+ `Invalid provider configuration: missing 'provide' token. ` +
+ `Expected format: { provide: Token, useClass/useValue/useFactory/useExisting: ... }`,
+ )
+ }
+
+ const provideToken = config.provide as InjectionToken
+
+ // Handle global enhancers (APP_GUARD, APP_PIPE, etc.)
+ if (this.isGlobalEnhancerToken(provideToken)) {
+ this.registerGlobalEnhancer(config, scopedTokens)
+ return
+ }
+
+ // Handle regular providers
+ this.registerRegularProvider(config, scopedTokens)
+ }
+
+ /**
+ * Check if token is a global enhancer token (APP_GUARD, APP_PIPE, etc.)
+ */
+ private isGlobalEnhancerToken(token: InjectionToken): boolean {
+ return (
+ token === (APP_GUARD as unknown as InjectionToken) ||
+ token === (APP_PIPE as unknown as InjectionToken) ||
+ token === (APP_INTERCEPTOR as unknown as InjectionToken) ||
+ token === (APP_FILTER as unknown as InjectionToken) ||
+ token === (APP_MIDDLEWARE as unknown as InjectionToken)
+ )
+ }
+
+ /**
+ * Register a global enhancer (APP_GUARD, APP_PIPE, etc.)
+ */
+ private registerGlobalEnhancer(config: any, scopedTokens: Constructor[]): void {
+ const provideToken = config.provide as InjectionToken
+ const enhancerType = this.getEnhancerType(provideToken)
+
+ if ('useClass' in config && config.useClass) {
+ const useClass = config.useClass as Constructor
+ this.registerSingleton(useClass)
+ scopedTokens.push(useClass)
+ const resolver = () => {
+ const instance = this.getProviderInstance(useClass)
+ if (enhancerType === 'middleware') {
+ return this.resolveMiddlewareDefinition(instance, useClass)
+ }
+ return instance
+ }
+ this.addGlobalEnhancerResolver(enhancerType, resolver)
+ return
+ }
+
+ if ('useExisting' in config && config.useExisting) {
+ const resolver = () => {
+ const existing = this.container.resolve(config.useExisting as any)
+ if (enhancerType === 'middleware') {
+ return this.resolveMiddlewareDefinition(existing)
+ }
+ return existing
+ }
+ this.addGlobalEnhancerResolver(enhancerType, resolver)
+ return
+ }
+
+ if ('useValue' in config) {
+ if (enhancerType === 'middleware') {
+ const lifecycleTarget = this.extractMiddlewareLifecycleTarget(config.useValue)
+ if (lifecycleTarget) {
+ this.registerLifecycleHandlers(lifecycleTarget)
+ }
+ const valueResolver = () => this.resolveMiddlewareDefinition(config.useValue)
+ this.addGlobalEnhancerResolver('middleware', valueResolver)
+ return
+ }
+
+ const valueResolver = () => config.useValue as unknown
+ this.registerLifecycleHandlers(config.useValue)
+ this.addGlobalEnhancerResolver(enhancerType, valueResolver)
+ return
+ }
+
+ if ('useFactory' in config && config.useFactory) {
+ if (enhancerType === 'middleware') {
+ const factoryResolver = () => {
+ const deps = (config.inject ?? []).map((t) => this.container.resolve(t as any))
+ const result = (config.useFactory as (...args: any[]) => unknown)(...deps)
+ return this.resolveMiddlewareDefinition(result)
+ }
+ this.addGlobalEnhancerResolver('middleware', factoryResolver)
+ return
+ }
+
+ const factoryResolver = () => {
+ const deps = (config.inject ?? []).map((t) => this.container.resolve(t as any))
+ return (config.useFactory as (...args: any[]) => unknown)(...deps)
+ }
+ this.addGlobalEnhancerResolver(enhancerType, factoryResolver)
+ return
+ }
+
+ throw new Error(
+ `Invalid global enhancer configuration for ${String(provideToken)}: ` +
+ `must specify useClass, useExisting, useValue, or useFactory`,
+ )
+ }
+
+ /**
+ * Get enhancer type from token
+ */
+ private getEnhancerType(token: InjectionToken): 'guard' | 'pipe' | 'interceptor' | 'filter' | 'middleware' {
+ if (token === (APP_GUARD as unknown as InjectionToken)) return 'guard'
+ if (token === (APP_PIPE as unknown as InjectionToken)) return 'pipe'
+ if (token === (APP_INTERCEPTOR as unknown as InjectionToken)) return 'interceptor'
+ if (token === (APP_FILTER as unknown as InjectionToken)) return 'filter'
+ if (token === (APP_MIDDLEWARE as unknown as InjectionToken)) return 'middleware'
+ throw new Error(`Unknown enhancer token: ${String(token)}`)
+ }
+
+ /**
+ * Add a global enhancer resolver
+ */
+ private addGlobalEnhancerResolver(
+ type: 'guard' | 'pipe' | 'interceptor' | 'filter' | 'middleware',
+ resolver: () => unknown,
+ ): void {
+ switch (type) {
+ case 'guard': {
+ this.pendingGlobalGuardResolvers.push(resolver as () => CanActivate)
+ break
+ }
+ case 'pipe': {
+ this.pendingGlobalPipeResolvers.push(resolver as () => PipeTransform)
+ break
+ }
+ case 'interceptor': {
+ this.pendingGlobalInterceptorResolvers.push(resolver as () => Interceptor)
+ break
+ }
+ case 'filter': {
+ this.pendingGlobalFilterResolvers.push(resolver as () => ExceptionFilter)
+ break
+ }
+ case 'middleware': {
+ this.pendingGlobalMiddlewareResolvers.push(resolver as () => MiddlewareDefinition)
+ break
+ }
+ }
+ }
+
+ private isHttpMiddleware(value: unknown): value is HttpMiddleware {
+ return typeof value === 'object' && value !== null && typeof (value as HttpMiddleware).use === 'function'
+ }
+
+ private isMiddlewareDefinition(value: unknown): value is MiddlewareDefinition {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ 'handler' in (value as Record) &&
+ this.isHttpMiddleware((value as MiddlewareDefinition).handler)
+ )
+ }
+
+ private extractMiddlewareLifecycleTarget(value: unknown): unknown | undefined {
+ if (this.isMiddlewareDefinition(value)) {
+ return value.handler
+ }
+
+ if (this.isHttpMiddleware(value)) {
+ return value
+ }
+
+ return undefined
+ }
+
+ private extractMiddlewareMetadata(source?: Constructor | MiddlewareMetadata): MiddlewareMetadata {
+ if (!source) {
+ return {}
+ }
+
+ if (typeof source === 'function') {
+ return getMiddlewareMetadata(source)
+ }
+
+ return source
+ }
+
+ private mergeMiddlewareMetadata(primary?: MiddlewareMetadata, fallback?: MiddlewareMetadata): MiddlewareMetadata {
+ return {
+ path: primary?.path ?? fallback?.path,
+ priority: primary?.priority ?? fallback?.priority,
+ }
+ }
+
+ private resolveMiddlewareDefinition(
+ value: unknown,
+ metadataSource?: Constructor | MiddlewareMetadata,
+ ): MiddlewareDefinition {
+ if (this.isMiddlewareDefinition(value)) {
+ const decoratorMetadata = this.extractMiddlewareMetadata(
+ metadataSource ?? (value.handler as unknown as Constructor),
+ )
+ const mergedMetadata = this.mergeMiddlewareMetadata(value, decoratorMetadata)
+ this.registerLifecycleHandlers(value.handler)
+ return this.normalizeMiddlewareDefinition({
+ handler: value.handler,
+ path: mergedMetadata.path,
+ priority: mergedMetadata.priority,
+ })
+ }
+
+ if (this.isHttpMiddleware(value)) {
+ const decoratorMetadata = this.extractMiddlewareMetadata(
+ metadataSource ?? ((value as HttpMiddleware).constructor as Constructor),
+ )
+ this.registerLifecycleHandlers(value)
+ return this.normalizeMiddlewareDefinition({
+ handler: value,
+ path: decoratorMetadata.path,
+ priority: decoratorMetadata.priority,
+ })
+ }
+
+ throw new ReferenceError('Invalid middleware configuration: expected Middleware or MiddlewareDefinition instance')
+ }
+
+ private normalizeMiddlewareDefinition(definition: MiddlewareDefinition): MiddlewareDefinition {
+ const path = definition.path ?? '/*'
+ const priority = definition.priority ?? 0
+ return {
+ handler: definition.handler,
+ path,
+ priority,
+ }
+ }
+
+ private describeMiddlewarePath(path: MiddlewarePath): string {
+ if (Array.isArray(path)) {
+ return path.map((entry) => (typeof entry === 'string' ? entry : entry.toString())).join(', ')
+ }
+ return typeof path === 'string' ? path : path.toString()
+ }
+
+ useGlobalMiddlewares(...middlewares: MiddlewareDefinition[]): void {
+ const normalized = middlewares.map((definition) => this.normalizeMiddlewareDefinition(definition))
+ normalized.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
+
+ for (const definition of normalized) {
+ this.globalEnhancers.middlewares.push(definition)
+ const path = definition.path ?? '/*'
+ const handlerName = definition.handler.constructor.name || 'AnonymousMiddleware'
+ const middlewareFn = async (context: Context, next: Next) => {
+ return await definition.handler.use(context, next)
+ }
+
+ if (Array.isArray(path)) {
+ for (const entry of path) {
+ this.app.use(entry as any, middlewareFn)
+ }
+ } else {
+ this.app.use(path as any, middlewareFn)
+ }
+
+ this.middlewareLogger.verbose(
+ `Registered middleware ${handlerName} on ${this.describeMiddlewarePath(path)}`,
+ colors.green(`+${performance.now().toFixed(2)}ms`),
+ )
+ }
+ }
+
+ /**
+ * Register a regular (non-enhancer) provider
+ */
+ private registerRegularProvider(config: any, scopedTokens: Constructor[]): void {
+ const provideToken = config.provide as InjectionToken
+
+ if ('useClass' in config && config.useClass) {
+ const useClass = config.useClass as Constructor
+ const isSingleton = config.singleton !== false // default true
+ this.registerClassProvider(provideToken, useClass, isSingleton, scopedTokens)
+ return
+ }
+
+ if ('useExisting' in config && config.useExisting) {
+ this.registerExistingProvider(provideToken, config.useExisting)
+ return
+ }
+
+ if ('useValue' in config) {
+ this.registerValueProvider(provideToken, config.useValue)
+ return
+ }
+
+ if ('useFactory' in config && config.useFactory) {
+ const isSingleton = config.singleton !== false // default true
+ this.registerFactoryProvider(provideToken, config.useFactory, config.inject ?? [], isSingleton)
+ return
+ }
+
+ throw new Error(
+ `Invalid provider configuration for ${String(provideToken)}: ` +
+ `must specify useClass, useExisting, useValue, or useFactory`,
+ )
+ }
+
+ /**
+ * Register a class provider
+ */
+ private registerClassProvider(
+ token: InjectionToken,
+ useClass: Constructor,
+ isSingleton: boolean,
+ scopedTokens: Constructor[],
+ ): void {
+ if (isSingleton) {
+ this.container.registerSingleton(token, useClass)
+ } else {
+ this.container.register(token, { useClass } as any)
+ }
+ const name = this.formatTokenName(useClass)
+ this.diLogger.debug(
+ 'Registered class provider',
+ colors.yellow(name),
+ colors.green(`+${performance.now().toFixed(2)}ms`),
+ )
+ scopedTokens.push(useClass)
+ }
+
+ /**
+ * Register an existing provider (alias)
+ */
+ private registerExistingProvider(token: InjectionToken, useExisting: InjectionToken): void {
+ this.container.register(token, { useToken: useExisting } as any)
+ this.diLogger.debug(
+ 'Registered existing provider alias',
+ colors.yellow(String(token)),
+ colors.green(`+${performance.now().toFixed(2)}ms`),
+ )
+ }
+
+ /**
+ * Register a value provider
+ */
+ private registerValueProvider(token: InjectionToken, useValue: unknown): void {
+ this.container.register(token, { useValue } as any)
+ this.registerLifecycleHandlers(useValue)
+ const name = this.formatTokenName(token as unknown as Constructor)
+ this.diLogger.debug(
+ 'Registered value provider',
+ colors.yellow(name),
+ colors.green(`+${performance.now().toFixed(2)}ms`),
+ )
+ }
+
+ /**
+ * Register a factory provider
+ */
+ private registerFactoryProvider(
+ token: InjectionToken,
+ useFactory: (...args: any[]) => unknown,
+ inject: InjectionToken[],
+ isSingleton: boolean,
+ ): void {
+ const factory = (c: DependencyContainer) => {
+ const deps = inject.map((t) => c.resolve(t as any))
+ return useFactory(...deps)
+ }
+
+ if (isSingleton) {
+ // tsyringe doesn't have registerSingleton+factory; emulate via register with cache
+ let cached: unknown
+ let created = false
+ this.container.register(token, {
+ useFactory: () => {
+ if (!created) {
+ cached = factory(this.container)
+ this.registerLifecycleHandlers(cached)
+ created = true
+ }
+ return cached
+ },
+ } as any)
+ } else {
+ this.container.register(token, { useFactory: factory } as any)
+ }
+
+ this.diLogger.debug(
+ 'Registered factory provider',
+ colors.yellow(String(token)),
+ colors.green(`+${performance.now().toFixed(2)}ms`),
+ )
+ }
+
+ private registerLifecycleHandlers(instance: unknown): void {
+ if (isOnModuleDestroyHook(instance)) {
+ this.registerLifecycleInstance(this.moduleDestroyHooks, instance)
+ }
+
+ if (isOnApplicationBootstrapHook(instance)) {
+ this.registerLifecycleInstance(this.applicationBootstrapHooks, instance)
+ }
+
+ if (isBeforeApplicationShutdownHook(instance)) {
+ this.registerLifecycleInstance(this.beforeApplicationShutdownHooks, instance)
+ }
+
+ if (isOnApplicationShutdownHook(instance)) {
+ this.registerLifecycleInstance(this.applicationShutdownHooks, instance)
+ }
+ }
+
+ private registerLifecycleInstance(collection: T[], instance: T): void {
+ if (!collection.includes(instance)) {
+ collection.push(instance)
+ }
+ }
+
+ private resolveInstance(token: Constructor): T {
+ try {
+ return this.container.resolve(token as unknown as InjectionToken)
+ } catch (error) {
+ /* c8 ignore start */
+ if (error instanceof Error && error.message.includes('Cannot inject the dependency ')) {
+ // Cannot inject the dependency "appService" at position #0 of "AppController" constructor.
+ const regexp = /Cannot inject the dependency "([^"]+)" at position #(\d+) of "([^"]+)" constructor\./
+ const match = error.message.match(regexp)
+ if (match) {
+ const [, dependency, position, constructor] = match
+ throw new ReferenceError(
+ `Cannot inject the dependency ${colors.yellow(dependency)} at position #${position} of "${colors.yellow(constructor)}" constructor.` +
+ `\n` +
+ `Please check if the dependency is registered in the container. Check import the dependency not the type.` +
+ `\n${colors.red(`- import type { ${dependency} } from "./service";`)}\n${colors.green(
+ `+ import { ${dependency} } from "./service";`,
+ )}`,
+ )
+ }
+ }
+ throw error
+ /* c8 ignore end */
+ }
+ }
+
+ private registerSingleton(token: Constructor): void {
+ const injectionToken = token as unknown as InjectionToken
+ if (!this.container.isRegistered(injectionToken, true)) {
+ this.container.registerSingleton(injectionToken, token)
+ const providerName = token.name && token.name.length > 0 ? token.name : token.toString()
+ this.diLogger.debug(
+ 'Registered singleton provider',
+ colors.yellow(providerName),
+ colors.green(`+${performance.now().toFixed(2)}ms`),
+ )
+ }
+ }
+
+ useGlobalGuards(...guards: CanActivate[]): void {
+ this.globalEnhancers.guards.push(...guards)
+ }
+
+ useGlobalPipes(...pipes: PipeTransform[]): void {
+ this.globalEnhancers.pipes.push(...pipes)
+ }
+
+ useGlobalInterceptors(...interceptors: Interceptor[]): void {
+ this.globalEnhancers.interceptors.push(...interceptors)
+ }
+
+ useGlobalFilters(...filters: ExceptionFilter[]): void {
+ this.globalEnhancers.filters.push(...filters)
+ }
+
+ private async registerModule(moduleClass: Constructor): Promise {
+ if (this.registeredModules.has(moduleClass)) {
+ return
+ }
+
+ this.registeredModules.add(moduleClass)
+ this.logger.debug('Registering module', moduleClass.name)
+
+ const metadata = getModuleMetadata(moduleClass)
+ const scopedTokens: Constructor[] = []
+
+ for (const importedModule of resolveModuleImports(metadata.imports)) {
+ await this.registerModule(importedModule)
+ }
+
+ for (const provider of metadata.providers ?? []) {
+ this.registerProvider(provider as any, scopedTokens)
+ }
+
+ for (const controller of metadata.controllers ?? []) {
+ this.registerSingleton(controller as Constructor)
+ scopedTokens.push(controller as Constructor)
+ // Defer controller instantiation until all modules are registered
+ this.pendingControllers.push(controller as Constructor)
+ }
+
+ // Defer lifecycle instantiation until all modules are registered
+ this.pendingLifecycleTokens.push(...scopedTokens)
+
+ this.logger.debug(
+ 'Module registration complete',
+ colors.yellow(moduleClass.name),
+ colors.green(`+${performance.now().toFixed(2)}ms`),
+ )
+ }
+
+ private async invokeModuleInit(tokens: Constructor[]): Promise {
+ const hasLifecycle = (ctor: Constructor | undefined): boolean => {
+ if (!ctor) return false
+ const proto = (ctor as any).prototype
+ if (!proto) return false
+ return (
+ typeof proto.onModuleInit === 'function' ||
+ typeof proto.onModuleDestroy === 'function' ||
+ typeof proto.onApplicationBootstrap === 'function' ||
+ typeof proto.beforeApplicationShutdown === 'function' ||
+ typeof proto.onApplicationShutdown === 'function'
+ )
+ }
+
+ for (const token of tokens) {
+ if (!token) continue
+ if (this.moduleInitCalled.has(token)) continue
+
+ // Only instantiate providers that participate in lifecycle hooks.
+ // Controllers are instantiated when routes are registered.
+ if (!hasLifecycle(token)) continue
+
+ const instance = this.getProviderInstance(token)
+ this.moduleInitCalled.add(token)
+
+ if (isOnModuleInitHook(instance)) {
+ await instance.onModuleInit()
+ }
+ }
+ }
+
+ private async callApplicationBootstrapHooks(): Promise {
+ if (this.applicationBootstrapInvoked) {
+ return
+ }
+
+ this.applicationBootstrapInvoked = true
+ for (const instance of this.applicationBootstrapHooks) {
+ await instance.onApplicationBootstrap()
+ }
+ }
+
+ private async callBeforeApplicationShutdownHooks(signal?: string): Promise {
+ for (const instance of [...this.beforeApplicationShutdownHooks].reverse()) {
+ await instance.beforeApplicationShutdown(signal)
+ }
+ }
+
+ private async callModuleDestroyHooks(): Promise {
+ for (const instance of [...this.moduleDestroyHooks].reverse()) {
+ await instance.onModuleDestroy()
+ }
+ }
+
+ private async callApplicationShutdownHooks(signal?: string): Promise {
+ for (const instance of [...this.applicationShutdownHooks].reverse()) {
+ await instance.onApplicationShutdown(signal)
+ }
+ }
+
+ private registerController(controller: Constructor): void {
+ const controllerInstance = this.getProviderInstance(controller)
+ const { prefix } = getControllerMetadata(controller)
+ const routes = getRoutesMetadata(controller)
+
+ for (const route of routes) {
+ const method = route.method.toUpperCase() as HTTPMethod
+ const fullPath = this.buildPath(prefix, route.path)
+
+ this.app.on(method, fullPath, async (context: Context) => {
+ return await HttpContext.run(context, async () => {
+ const handler = Reflect.get(controllerInstance, route.handlerName) as (...args: any[]) => any
+ const executionContext = createExecutionContext(this.container, controller, handler)
+
+ try {
+ await this.executeGuards(controller, route.handlerName, executionContext)
+
+ const response = await this.executeInterceptors(
+ controller,
+ route.handlerName,
+ executionContext,
+ async () => {
+ const args = await this.resolveArguments(
+ controller,
+ route.handlerName,
+ handler,
+ context,
+ executionContext,
+ )
+ const result = await handler.apply(controllerInstance, args)
+ return this.transformResult(context, result)
+ },
+ )
+
+ return response
+ } catch (error) {
+ return await this.handleException(controller, route.handlerName, error, executionContext, context)
+ }
+ })
+ })
+
+ this.routerLogger.verbose(
+ `Mapped route ${method} ${fullPath} -> ${controller.name}.${String(route.handlerName)}`,
+
+ colors.green(`+${performance.now().toFixed(2)}ms`),
+ )
+ }
+ }
+
+ private buildPath(prefix: string, routePath: string): string {
+ const globalPrefix = this.options.globalPrefix ?? ''
+ const pieces = [globalPrefix, prefix, routePath]
+ .map((segment) => segment?.trim())
+ .filter(Boolean)
+ .map((segment) => (segment!.startsWith('/') ? segment : `/${segment}`))
+
+ const normalized = pieces.join('').replaceAll(/[\\/]+/g, '/')
+ if (normalized.length > 1 && normalized.endsWith('/')) {
+ return normalized.slice(0, -1)
+ }
+
+ return normalized || '/'
+ }
+
+ private async executeGuards(
+ controller: Constructor,
+ handlerName: string | symbol,
+ context: ReturnType,
+ ): Promise {
+ const guards = [
+ ...this.globalEnhancers.guards,
+ ...collectGuards(controller, handlerName).map((ctor) => {
+ this.registerSingleton(ctor)
+ return this.getProviderInstance(ctor)
+ }),
+ ]
+
+ for (const guard of guards) {
+ if (!guard) {
+ continue
+ }
+ const canActivate = await guard.canActivate(context)
+ if (!canActivate) {
+ this.logger.warn(`Guard blocked ${controller.name}.${String(handlerName)} execution`)
+ throw new ForbiddenException()
+ }
+ }
+ }
+
+ private async executeInterceptors(
+ controller: Constructor,
+ handlerName: string | symbol,
+ executionContext: ReturnType,
+ finalHandler: () => Promise,
+ ): Promise {
+ const interceptorInstances = [
+ ...this.globalEnhancers.interceptors,
+ ...collectInterceptors(controller, handlerName).map((ctor) => {
+ this.registerSingleton(ctor)
+ return this.getProviderInstance(ctor)
+ }),
+ ]
+
+ const honoContext = HttpContext.getValue('hono')
+
+ const callHandler: CallHandler = {
+ handle: async (): Promise => this.ensureResponse(honoContext, await finalHandler()),
+ }
+
+ const interceptors = interceptorInstances.filter(Boolean).reverse()
+
+ const dispatch: CallHandler = interceptors.reduce(
+ (next, interceptor): CallHandler => ({
+ handle: () => Promise.resolve(interceptor.intercept(executionContext, next)),
+ }),
+ callHandler,
+ )
+
+ const result = await dispatch.handle()
+ return this.ensureResponse(honoContext, result)
+ }
+
+ private async handleException(
+ controller: Constructor,
+ handlerName: string | symbol,
+ error: unknown,
+ executionContext: ReturnType,
+ context: Context,
+ ): Promise {
+ const filters = [
+ ...this.globalEnhancers.filters,
+ ...collectFilters(controller, handlerName).map((ctor) => {
+ this.registerSingleton(ctor)
+ return this.getProviderInstance(ctor)
+ }),
+ ]
+ for (const filter of filters) {
+ if (!filter) {
+ continue
+ }
+ const maybeResponse = await filter.catch(error as Error, executionContext)
+ if (maybeResponse) {
+ return this.ensureResponse(context, maybeResponse)
+ }
+ }
+
+ if (error instanceof HttpException) {
+ return this.json(context, error.getResponse(), error.getStatus())
+ }
+
+ const message =
+ error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}`.trim() : String(error)
+ this.logger.error(`Unhandled error ${message}`)
+ const response = {
+ statusCode: 500,
+ message: 'Internal server error',
+ }
+
+ return this.json(context, response, 500)
+ }
+
+ private transformResult(context: Context, result: unknown): unknown {
+ if (result === undefined) {
+ return context.res
+ }
+
+ return result
+ }
+
+ private ensureResponse(context: Context, payload: unknown): Response {
+ if (payload instanceof Response) {
+ return payload
+ }
+
+ if (payload === context.res) {
+ return context.res
+ }
+
+ if (payload === undefined) {
+ return context.res
+ }
+
+ if (typeof payload === 'string') {
+ return this.markGeneratedResponse(new Response(payload as BodyInit))
+ }
+
+ if (payload instanceof ArrayBuffer) {
+ return this.markGeneratedResponse(new Response(payload as BodyInit))
+ }
+
+ if (ArrayBuffer.isView(payload)) {
+ return this.markGeneratedResponse(new Response(payload as BodyInit))
+ }
+
+ if (payload instanceof ReadableStream) {
+ return this.markGeneratedResponse(new Response(payload))
+ }
+
+ return this.markGeneratedResponse(context.json(payload))
+ }
+
+ private json(context: Context, payload: unknown, status: number): Response {
+ const normalizedPayload = payload === undefined ? null : payload
+ return new Response(JSON.stringify(normalizedPayload), {
+ status,
+ headers: {
+ 'content-type': 'application/json',
+ },
+ })
+ }
+
+ private markGeneratedResponse(response: Response): Response {
+ Reflect.set(response as unknown as Record, GENERATED_RESPONSE, true)
+ return response
+ }
+
+ private getGlobalAndHandlerPipes(controller: Constructor, handlerName: string | symbol): PipeTransform[] {
+ const handlerPipeInstances = collectPipes(controller, handlerName).map((ctor) => {
+ this.registerSingleton(ctor)
+ return this.getProviderInstance(ctor)
+ })
+ return [...this.globalEnhancers.pipes, ...handlerPipeInstances].filter(Boolean)
+ }
+
+ private async resolveArguments(
+ controller: Constructor,
+ handlerName: string | symbol,
+ handler: Function,
+ context: Context,
+ executionContext: ReturnType,
+ ): Promise {
+ const paramsMetadata = this.getParametersMetadata(controller, handlerName, handler, context)
+ if (isDebugEnabled()) {
+ this.logger.debug('Resolved params metadata', {
+ controller: controller.name,
+ handler: handlerName.toString(),
+ paramsMetadata,
+ })
+ }
+ const maxIndex = paramsMetadata.length > 0 ? Math.max(...paramsMetadata.map((item) => item.index)) : -1
+ const args: unknown[] = Array.from({ length: maxIndex + 1 })
+ const sharedPipes = this.getGlobalAndHandlerPipes(controller, handlerName)
+
+ // console.debug('Params metadata', controller.name, handlerName, paramsMetadata);
+ for (const metadata of paramsMetadata) {
+ const value = await this.resolveParameterValue(metadata, context, executionContext)
+ const transformed = await this.applyPipes(value, metadata, sharedPipes)
+ args[metadata.index] = transformed
+ }
+
+ return args.length > 0 ? args : [context]
+ }
+
+ /* c8 ignore start */
+ private getParametersMetadata(
+ controller: Constructor,
+ handlerName: string | symbol,
+ handler: Function,
+ context: Context,
+ ): RouteParamMetadataItem[] {
+ const controllerMetadata = getRouteArgsMetadata(controller.prototype, handlerName)
+ const paramTypes: Constructor[] = (Reflect.getMetadata('design:paramtypes', controller.prototype, handlerName) ||
+ []) as Constructor[]
+ const handlerParamLength = handler.length
+
+ const indexed = new Map()
+
+ for (const metadata of controllerMetadata) {
+ indexed.set(metadata.index, {
+ ...metadata,
+ metatype: metadata.metatype ?? paramTypes[metadata.index],
+ })
+ }
+
+ const potentialIndexes = [...indexed.keys(), paramTypes.length - 1, handlerParamLength - 1, -1]
+
+ let maxIndex = -1
+ for (const value of potentialIndexes) {
+ if (value > maxIndex) {
+ maxIndex = value
+ }
+ }
+
+ const items: RouteParamMetadataItem[] = []
+
+ if (maxIndex < 0) {
+ return items
+ }
+
+ for (let index = 0; index <= maxIndex; index += 1) {
+ const existing = indexed.get(index)
+ if (existing) {
+ items.push(existing)
+ } else {
+ const shouldInferContext = index < Math.max(paramTypes.length, handlerParamLength) && handlerParamLength > 0
+ if (isDebugEnabled()) {
+ this.logger.debug('Inferred context parameter', {
+ controller: controller.name,
+ handler: handlerName.toString(),
+ index,
+ paramTypesLength: paramTypes.length,
+ handlerParamLength,
+ })
+ }
+ if (shouldInferContext) {
+ items.push({
+ index,
+ type: RouteParamtypes.CONTEXT,
+ metatype: paramTypes[index] ?? context.constructor,
+ })
+ }
+ }
+ }
+
+ return items.sort((a, b) => a.index - b.index)
+ }
+ /* c8 ignore end */
+
+ private async resolveParameterValue(
+ metadata: RouteParamMetadataItem,
+ context: Context,
+ executionContext: ReturnType,
+ ): Promise {
+ if (metadata.factory) {
+ return metadata.factory(context, executionContext)
+ }
+
+ switch (metadata.type) {
+ case RouteParamtypes.REQUEST: {
+ return context.req
+ }
+ case RouteParamtypes.BODY: {
+ return await this.readBody(context)
+ }
+ case RouteParamtypes.QUERY: {
+ return metadata.data ? context.req.query(metadata.data) : context.req.query()
+ }
+ case RouteParamtypes.PARAM: {
+ return metadata.data ? context.req.param(metadata.data) : context.req.param()
+ }
+ case RouteParamtypes.HEADERS: {
+ if (metadata.data) {
+ return context.req.header(metadata.data)
+ }
+ return context.req.raw.headers
+ }
+
+ default: {
+ return context
+ }
+ }
+ }
+
+ private async applyPipes(
+ value: unknown,
+ metadata: RouteParamMetadataItem,
+ sharedPipes: PipeTransform[],
+ ): Promise {
+ const paramPipes = (metadata.pipes || []).filter(Boolean).map((ctor) => {
+ this.registerSingleton(ctor)
+ return this.getProviderInstance(ctor)
+ })
+ const pipes = [...sharedPipes, ...paramPipes]
+
+ if (pipes.length === 0) {
+ return value
+ }
+
+ const argumentMetadata: ArgumentMetadata = {
+ type: metadata.type,
+ data: metadata.data,
+ metatype: metadata.metatype,
+ } as ArgumentMetadata
+
+ let currentValue = value
+ for (const pipe of pipes) {
+ currentValue = await pipe.transform(currentValue, argumentMetadata)
+ }
+
+ return currentValue
+ }
+
+ private async readBody(context: Context): Promise {
+ const cacheKey = '__framework_cached_body__'
+ if (context.get(cacheKey) !== undefined) {
+ return context.get(cacheKey)
+ }
+
+ const contentType = context.req.header('content-type') ?? ''
+
+ if (!contentType.includes('application/json')) {
+ context.set(cacheKey, null)
+ return null
+ }
+
+ try {
+ const body = await context.req.json()
+ context.set(cacheKey, body)
+ return body
+ } catch (error) {
+ throw new BadRequestException(
+ {
+ statusCode: 400,
+ message: 'Invalid JSON payload',
+ },
+ 'Invalid JSON payload',
+ { cause: error },
+ )
+ }
+ }
+}
+
+export async function createApplication(
+ rootModule: Constructor,
+ options: ApplicationOptions = {},
+): Promise {
+ const app = new HonoHttpApplication(rootModule, options)
+ await app.init()
+ return app
+}
diff --git a/be/packages/framework/src/constants.ts b/be/packages/framework/src/constants.ts
new file mode 100644
index 00000000..0fbbdec6
--- /dev/null
+++ b/be/packages/framework/src/constants.ts
@@ -0,0 +1,23 @@
+export const MODULE_METADATA = Symbol('MODULE_METADATA')
+export const CONTROLLER_METADATA = Symbol('CONTROLLER_METADATA')
+export const ROUTES_METADATA = Symbol('ROUTES_METADATA')
+export const GUARDS_METADATA = Symbol('GUARDS_METADATA')
+export const PIPES_METADATA = Symbol('PIPES_METADATA')
+export const INTERCEPTORS_METADATA = Symbol('INTERCEPTORS_METADATA')
+export const EXCEPTION_FILTERS_METADATA = Symbol('EXCEPTION_FILTERS_METADATA')
+export const MIDDLEWARE_METADATA = Symbol('MIDDLEWARE_METADATA')
+
+export const ROUTE_ARGS_METADATA = Symbol('ROUTE_ARGS_METADATA')
+
+export function isDebugEnabled(): boolean {
+ return process.env.DEBUG === 'true'
+}
+export const API_TAGS_METADATA = Symbol('API_TAGS_METADATA')
+export const API_OPERATION_METADATA = Symbol('API_OPERATION_METADATA')
+
+// Global enhancer provider tokens (NestJS-like)
+export const APP_GUARD = Symbol('APP_GUARD')
+export const APP_PIPE = Symbol('APP_PIPE')
+export const APP_INTERCEPTOR = Symbol('APP_INTERCEPTOR')
+export const APP_FILTER = Symbol('APP_FILTER')
+export const APP_MIDDLEWARE = Symbol('APP_MIDDLEWARE')
diff --git a/be/packages/framework/src/context/http-context.ts b/be/packages/framework/src/context/http-context.ts
new file mode 100644
index 00000000..196f097d
--- /dev/null
+++ b/be/packages/framework/src/context/http-context.ts
@@ -0,0 +1,51 @@
+import { AsyncLocalStorage } from 'node:async_hooks'
+
+import type { Context } from 'hono'
+
+export interface HttpContextValues {
+ hono: Context
+}
+
+interface HttpContextStore {
+ values: HttpContextValues
+}
+
+const httpContextStorage = new AsyncLocalStorage()
+
+function ensureStore(): HttpContextStore {
+ const store = httpContextStorage.getStore()
+ if (!store) {
+ throw new Error('HTTPContext is not available outside of request scope')
+ }
+ return store
+}
+
+export const HttpContext = {
+ async run(context: Context, fn: () => Promise | T): Promise {
+ return await new Promise((resolve, reject) => {
+ httpContextStorage.run({ values: { hono: context } }, () => {
+ Promise.resolve(fn()).then(resolve).catch(reject)
+ })
+ })
+ },
+
+ get(): T {
+ return ensureStore().values as unknown as T
+ },
+
+ getValue(key: TKey): HttpContextValues[TKey] {
+ return ensureStore().values[key]
+ },
+
+ setValue(key: TKey, value: HttpContextValues[TKey]): void {
+ ensureStore().values[key] = value
+ },
+
+ assign(values: Partial): void {
+ Object.assign(ensureStore().values, values)
+ },
+
+ setContext(context: Context): void {
+ this.setValue('hono', context)
+ },
+}
diff --git a/be/packages/framework/src/decorators/apply-decorators.ts b/be/packages/framework/src/decorators/apply-decorators.ts
new file mode 100644
index 00000000..57630567
--- /dev/null
+++ b/be/packages/framework/src/decorators/apply-decorators.ts
@@ -0,0 +1,25 @@
+/* @see https://github.com/nestjs/nest/blob/master/packages/common/decorators/core/apply-decorators.ts
+ *
+ * Function that returns a new decorator that applies all decorators provided by param
+ *
+ * Useful to build new decorators (or a decorator factory) encapsulating multiple decorators related with the same feature
+ *
+ * @param decorators one or more decorators (e.g., `ApplyGuard(...)`)
+ *
+ * @publicApi
+ */
+export function applyDecorators(...decorators: Array) {
+ return (
+ target: TFunction | object,
+ propertyKey?: string | symbol,
+ descriptor?: TypedPropertyDescriptor,
+ ) => {
+ for (const decorator of decorators) {
+ if (typeof target === 'function' && !descriptor) {
+ ;(decorator as ClassDecorator)(target)
+ continue
+ }
+ ;(decorator as MethodDecorator | PropertyDecorator)(target, propertyKey!, descriptor!)
+ }
+ }
+}
diff --git a/be/packages/framework/src/decorators/controller.ts b/be/packages/framework/src/decorators/controller.ts
new file mode 100644
index 00000000..ff2d5603
--- /dev/null
+++ b/be/packages/framework/src/decorators/controller.ts
@@ -0,0 +1,28 @@
+import { injectable } from 'tsyringe'
+
+import { CONTROLLER_METADATA } from '../constants'
+import type { Constructor } from '../interfaces'
+
+export interface ControllerMetadata {
+ prefix: string
+}
+
+export function Controller(prefix = ''): ClassDecorator {
+ return (target) => {
+ Reflect.defineMetadata(
+ CONTROLLER_METADATA,
+ {
+ prefix,
+ } satisfies ControllerMetadata,
+ target as unknown as Constructor,
+ )
+
+ injectable()(target as unknown as Constructor)
+ }
+}
+
+export function getControllerMetadata(target: Constructor): ControllerMetadata {
+ return (Reflect.getMetadata(CONTROLLER_METADATA, target) || {
+ prefix: '',
+ }) as ControllerMetadata
+}
diff --git a/be/packages/framework/src/decorators/enhancers.ts b/be/packages/framework/src/decorators/enhancers.ts
new file mode 100644
index 00000000..6399692d
--- /dev/null
+++ b/be/packages/framework/src/decorators/enhancers.ts
@@ -0,0 +1,44 @@
+import { EXCEPTION_FILTERS_METADATA, GUARDS_METADATA, INTERCEPTORS_METADATA, PIPES_METADATA } from '../constants'
+import type { CanActivate, Constructor, ExceptionFilter, Interceptor, PipeTransform } from '../interfaces'
+
+type DecoratorTarget = object
+
+type EnhancerDecorator = ClassDecorator &
+ MethodDecorator &
+ ((target: DecoratorTarget, propertyKey?: string | symbol) => void)
+
+function appendMetadata(metadataKey: symbol, values: T[], target: DecoratorTarget, propertyKey?: string | symbol) {
+ const existing: T[] = (
+ propertyKey !== undefined
+ ? Reflect.getMetadata(metadataKey, target, propertyKey) || []
+ : Reflect.getMetadata(metadataKey, target) || []
+ ) as T[]
+
+ if (propertyKey !== undefined) {
+ Reflect.defineMetadata(metadataKey, [...existing, ...values], target, propertyKey)
+ } else {
+ Reflect.defineMetadata(metadataKey, [...existing, ...values], target)
+ }
+}
+
+function createEnhancerDecorator(metadataKey: symbol) {
+ return (...items: T[]): EnhancerDecorator =>
+ (target: DecoratorTarget, propertyKey?: string | symbol) => {
+ appendMetadata(metadataKey, items, propertyKey ? target : (target as (...args: any[]) => any), propertyKey)
+ }
+}
+
+export const UseGuards = createEnhancerDecorator>(GUARDS_METADATA)
+export const UsePipes = createEnhancerDecorator>(PIPES_METADATA)
+export const UseInterceptors = createEnhancerDecorator>(INTERCEPTORS_METADATA)
+export const UseFilters = createEnhancerDecorator>(EXCEPTION_FILTERS_METADATA)
+
+export function getEnhancerMetadata(
+ metadataKey: symbol,
+ target: DecoratorTarget,
+ propertyKey?: string | symbol,
+): Array> {
+ return ((propertyKey !== undefined
+ ? Reflect.getMetadata(metadataKey, target, propertyKey)
+ : Reflect.getMetadata(metadataKey, target)) || []) as Array>
+}
diff --git a/be/packages/framework/src/decorators/http-methods.ts b/be/packages/framework/src/decorators/http-methods.ts
new file mode 100644
index 00000000..05763bb0
--- /dev/null
+++ b/be/packages/framework/src/decorators/http-methods.ts
@@ -0,0 +1,31 @@
+import { ROUTES_METADATA } from '../constants'
+import type { Constructor, RouteDefinition } from '../interfaces'
+
+function attachRoute(target: Constructor, route: RouteDefinition) {
+ const routes: RouteDefinition[] = Reflect.getMetadata(ROUTES_METADATA, target) || []
+ Reflect.defineMetadata(ROUTES_METADATA, [...routes, route], target)
+}
+
+function createRouteDecorator(method: string) {
+ return (path = ''): MethodDecorator =>
+ (target, propertyKey) => {
+ const controller = target.constructor as Constructor
+ attachRoute(controller, {
+ method,
+ path,
+ handlerName: propertyKey,
+ })
+ }
+}
+
+export const Get = createRouteDecorator('GET')
+export const Post = createRouteDecorator('POST')
+export const Put = createRouteDecorator('PUT')
+export const Patch = createRouteDecorator('PATCH')
+export const Delete = createRouteDecorator('DELETE')
+export const Options = createRouteDecorator('OPTIONS')
+export const Head = createRouteDecorator('HEAD')
+
+export function getRoutesMetadata(target: Constructor): RouteDefinition[] {
+ return (Reflect.getMetadata(ROUTES_METADATA, target) || []) as RouteDefinition[]
+}
diff --git a/be/packages/framework/src/decorators/middleware.ts b/be/packages/framework/src/decorators/middleware.ts
new file mode 100644
index 00000000..33c7ee91
--- /dev/null
+++ b/be/packages/framework/src/decorators/middleware.ts
@@ -0,0 +1,14 @@
+import { MIDDLEWARE_METADATA } from '../constants'
+import type { MiddlewareMetadata } from '../interfaces'
+
+export type MiddlewareDecoratorOptions = MiddlewareMetadata
+
+export function Middleware(options: MiddlewareDecoratorOptions = {}): ClassDecorator {
+ return (target) => {
+ Reflect.defineMetadata(MIDDLEWARE_METADATA, options, target)
+ }
+}
+
+export function getMiddlewareMetadata(target: Function): MiddlewareMetadata {
+ return (Reflect.getMetadata(MIDDLEWARE_METADATA, target) as MiddlewareMetadata | undefined) ?? {}
+}
diff --git a/be/packages/framework/src/decorators/module.ts b/be/packages/framework/src/decorators/module.ts
new file mode 100644
index 00000000..d4576d95
--- /dev/null
+++ b/be/packages/framework/src/decorators/module.ts
@@ -0,0 +1,39 @@
+import { MODULE_METADATA } from '../constants'
+import type { Constructor, ForwardReference, ModuleImport, ModuleMetadata } from '../interfaces'
+
+function isForwardReference(value: ModuleImport): value is ForwardReference {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ 'forwardRef' in value &&
+ typeof (value as ForwardReference).forwardRef === 'function'
+ )
+}
+
+export function resolveModuleImport(target: ModuleImport): Constructor {
+ if (isForwardReference(target)) {
+ return target.forwardRef()
+ }
+
+ return target as Constructor
+}
+
+export function forwardRef(factory: () => Constructor): ForwardReference {
+ return {
+ forwardRef: factory,
+ }
+}
+
+export function Module(metadata: ModuleMetadata): ClassDecorator {
+ return (target) => {
+ Reflect.defineMetadata(MODULE_METADATA, metadata, target as unknown as Constructor)
+ }
+}
+
+export function getModuleMetadata(target: Constructor): ModuleMetadata {
+ return (Reflect.getMetadata(MODULE_METADATA, target) || {}) as ModuleMetadata
+}
+
+export function resolveModuleImports(imports: ModuleImport[] = []): Constructor[] {
+ return imports.map((item) => resolveModuleImport(item))
+}
diff --git a/be/packages/framework/src/decorators/openapi.ts b/be/packages/framework/src/decorators/openapi.ts
new file mode 100644
index 00000000..d40afd45
--- /dev/null
+++ b/be/packages/framework/src/decorators/openapi.ts
@@ -0,0 +1,76 @@
+import { API_OPERATION_METADATA, API_TAGS_METADATA } from '../constants'
+import type { Constructor } from '../interfaces'
+
+export interface ApiOperationOptions {
+ summary?: string
+ description?: string
+ operationId?: string
+ deprecated?: boolean
+ externalDocs?: {
+ description?: string
+ url: string
+ }
+ tags?: string[]
+}
+
+function getStore(target: object, key: symbol, propertyKey?: string | symbol): any {
+ if (propertyKey) {
+ return Reflect.getMetadata(key, target, propertyKey) as unknown
+ }
+ return Reflect.getMetadata(key, target) as unknown
+}
+
+function setStore(target: object, key: symbol, value: unknown, propertyKey?: string | symbol): void {
+ if (propertyKey) {
+ Reflect.defineMetadata(key, value, target, propertyKey)
+ } else {
+ Reflect.defineMetadata(key, value, target)
+ }
+}
+
+export function ApiTags(...tags: string[]): ClassDecorator & MethodDecorator {
+ return (target: any, propertyKey?: string | symbol) => {
+ const existing = (
+ propertyKey
+ ? getStore(target, API_TAGS_METADATA, propertyKey)
+ : getStore(target.prototype ?? target, API_TAGS_METADATA)
+ ) as string[] | undefined
+ const next = Array.from(new Set([...(existing ?? []), ...tags.filter(Boolean)]))
+ if (propertyKey) {
+ setStore(target, API_TAGS_METADATA, next, propertyKey)
+ } else {
+ setStore(target.prototype ?? target, API_TAGS_METADATA, next)
+ }
+ }
+}
+
+export function ApiDoc(options: ApiOperationOptions): ClassDecorator & MethodDecorator {
+ return (target: any, propertyKey?: string | symbol) => {
+ if (propertyKey) {
+ const existing = (getStore(target, API_OPERATION_METADATA, propertyKey) || {}) as ApiOperationOptions
+ setStore(target, API_OPERATION_METADATA, { ...existing, ...options }, propertyKey)
+ } else {
+ const proto = target.prototype ?? target
+ const existing = (getStore(proto, API_OPERATION_METADATA) || {}) as ApiOperationOptions
+ setStore(proto, API_OPERATION_METADATA, { ...existing, ...options })
+ }
+ }
+}
+
+export function getApiTags(target: Constructor): string[]
+export function getApiTags(target: object, propertyKey: string | symbol): string[]
+export function getApiTags(target: object, propertyKey?: string | symbol): string[] {
+ const store = propertyKey
+ ? (getStore(target, API_TAGS_METADATA, propertyKey) as string[] | undefined)
+ : (getStore((target as Constructor).prototype ?? target, API_TAGS_METADATA) as string[] | undefined)
+ return store ? [...store] : []
+}
+
+export function getApiDoc(target: Constructor): ApiOperationOptions
+export function getApiDoc(target: object, propertyKey: string | symbol): ApiOperationOptions
+export function getApiDoc(target: object, propertyKey?: string | symbol): ApiOperationOptions {
+ const store = propertyKey
+ ? (getStore(target, API_OPERATION_METADATA, propertyKey) as ApiOperationOptions | undefined)
+ : (getStore((target as Constructor).prototype ?? target, API_OPERATION_METADATA) as ApiOperationOptions | undefined)
+ return store ? { ...store } : {}
+}
diff --git a/be/packages/framework/src/decorators/params.ts b/be/packages/framework/src/decorators/params.ts
new file mode 100644
index 00000000..bdfb9c7d
--- /dev/null
+++ b/be/packages/framework/src/decorators/params.ts
@@ -0,0 +1,45 @@
+import { ROUTE_ARGS_METADATA } from '../constants'
+import type { Constructor, PipeTransform, RouteParamMetadataItem } from '../interfaces'
+import { RouteParamtypes } from '../interfaces'
+
+type ParamDecoratorFactory = (data?: string, ...pipes: Array>) => ParameterDecorator
+
+function appendMetadata(target: object, propertyKey: string | symbol, metadata: RouteParamMetadataItem) {
+ const existing: RouteParamMetadataItem[] = (Reflect.getMetadata(ROUTE_ARGS_METADATA, target, propertyKey) ||
+ []) as RouteParamMetadataItem[]
+
+ Reflect.defineMetadata(ROUTE_ARGS_METADATA, [...existing, metadata], target, propertyKey)
+}
+
+function createParamDecorator(type: RouteParamtypes, factory?: (...args: unknown[]) => unknown): ParamDecoratorFactory {
+ return (data?: string, ...pipes: Array>) =>
+ (target, propertyKey, parameterIndex) => {
+ appendMetadata(target, propertyKey!, {
+ index: parameterIndex,
+ type,
+ data,
+ pipes,
+ factory,
+ })
+ }
+}
+
+export const Body = createParamDecorator(RouteParamtypes.BODY)
+export const Query = createParamDecorator(RouteParamtypes.QUERY)
+export const Param = createParamDecorator(RouteParamtypes.PARAM)
+export const Headers = createParamDecorator(RouteParamtypes.HEADERS)
+export const Req = createParamDecorator(RouteParamtypes.REQUEST)
+export const ContextParam = createParamDecorator(RouteParamtypes.CONTEXT)
+
+export function getRouteArgsMetadata(target: object, propertyKey: string | symbol): RouteParamMetadataItem[] {
+ const metadata: RouteParamMetadataItem[] = (Reflect.getMetadata(ROUTE_ARGS_METADATA, target, propertyKey) ||
+ []) as RouteParamMetadataItem[]
+
+ const paramTypes: Constructor[] = (Reflect.getMetadata('design:paramtypes', target, propertyKey) ||
+ []) as Constructor[]
+
+ return metadata.map((item) => ({
+ ...item,
+ metatype: paramTypes[item.index],
+ }))
+}
diff --git a/be/packages/framework/src/events/index.ts b/be/packages/framework/src/events/index.ts
new file mode 100644
index 00000000..1b8facb6
--- /dev/null
+++ b/be/packages/framework/src/events/index.ts
@@ -0,0 +1,318 @@
+import { injectable } from 'tsyringe'
+
+import { Module } from '../decorators/module'
+import type { Constructor, OnModuleDestroy, OnModuleInit } from '../interfaces'
+import { createLogger } from '../logger'
+import { ContainerRef } from '../utils/container-ref'
+
+const logger = createLogger('Events:EmitDecorator')
+
+function formatError(error: unknown): string {
+ if (error instanceof Error) {
+ return `${error.name}: ${error.message}`
+ }
+ return String(error)
+}
+
+// Minimal Redis client interface used by the module
+export type RedisClient = {
+ publish: (...args: any[]) => any
+ duplicate: () => RedisClient
+ subscribe: (...args: any[]) => any
+ unsubscribe: (...args: any[]) => any
+ quit: () => any
+ on: (event: string, listener: (...args: any[]) => void) => any
+}
+
+// Metadata keys
+const EVENT_LISTENER_METADATA = Symbol.for('events.listener')
+const EVENT_EMIT_DECORATOR = Symbol.for('events.emit')
+
+// Track classes that declare @OnEvent handlers
+const GLOBAL_EVENT_LISTENER_CLASSES = new Set()
+
+// Types
+export interface EventMessage {
+ event: string
+ payload: T
+ emittedAt: string
+}
+
+export type EventHandler = (payload: T) => Promise