bertui 1.1.8 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,151 @@
1
+ // bertui/src/hydration/index.js
2
+ // Partial hydration - only hydrate interactive components, not whole page
3
+
4
+ import logger from '../logger/logger.js';
5
+
6
+ /**
7
+ * Markers that make a component interactive (needs JS hydration)
8
+ */
9
+ const INTERACTIVE_MARKERS = [
10
+ // React hooks
11
+ 'useState', 'useEffect', 'useReducer', 'useCallback',
12
+ 'useMemo', 'useRef', 'useContext', 'useLayoutEffect',
13
+ 'useTransition', 'useDeferredValue', 'useSyncExternalStore',
14
+ // Event handlers
15
+ 'onClick', 'onChange', 'onSubmit', 'onInput', 'onFocus',
16
+ 'onBlur', 'onMouseEnter', 'onMouseLeave', 'onKeyDown',
17
+ 'onKeyUp', 'onScroll', 'onDrop', 'onDrag', 'onTouchStart',
18
+ // Browser APIs in component body
19
+ 'window.', 'document.', 'localStorage.', 'sessionStorage.',
20
+ 'navigator.', 'fetch(', 'WebSocket', 'EventSource',
21
+ 'setTimeout(', 'setInterval(', 'requestAnimationFrame(',
22
+ ];
23
+
24
+ /**
25
+ * Scan source code to determine if a component needs hydration
26
+ */
27
+ export function needsHydration(sourceCode) {
28
+ for (const marker of INTERACTIVE_MARKERS) {
29
+ if (sourceCode.includes(marker)) {
30
+ return true;
31
+ }
32
+ }
33
+ return false;
34
+ }
35
+
36
+ /**
37
+ * Get which specific interactive features a component uses
38
+ */
39
+ export function getInteractiveFeatures(sourceCode) {
40
+ const features = [];
41
+
42
+ const hooks = ['useState', 'useEffect', 'useReducer', 'useCallback', 'useMemo', 'useRef'];
43
+ for (const hook of hooks) {
44
+ if (sourceCode.includes(hook)) features.push({ type: 'hook', name: hook });
45
+ }
46
+
47
+ const events = ['onClick', 'onChange', 'onSubmit', 'onFocus', 'onBlur', 'onKeyDown'];
48
+ for (const event of events) {
49
+ if (sourceCode.includes(event)) features.push({ type: 'event', name: event });
50
+ }
51
+
52
+ const apis = ['fetch(', 'WebSocket', 'localStorage.', 'sessionStorage.'];
53
+ for (const api of apis) {
54
+ if (sourceCode.includes(api)) features.push({ type: 'api', name: api });
55
+ }
56
+
57
+ return features;
58
+ }
59
+
60
+ /**
61
+ * Analyze all routes and classify them
62
+ * Returns: { static: [], interactive: [], mixed: [] }
63
+ */
64
+ export async function analyzeRoutes(routes) {
65
+ const result = { static: [], interactive: [], mixed: [] };
66
+
67
+ for (const route of routes) {
68
+ try {
69
+ const sourceCode = await Bun.file(route.path).text();
70
+ const isServerIsland = sourceCode.includes('export const render = "server"');
71
+ const interactive = needsHydration(sourceCode);
72
+ const features = getInteractiveFeatures(sourceCode);
73
+
74
+ const analyzed = {
75
+ ...route,
76
+ interactive,
77
+ isServerIsland,
78
+ features,
79
+ hydrationMode: isServerIsland
80
+ ? 'none'
81
+ : interactive
82
+ ? 'full'
83
+ : 'none',
84
+ };
85
+
86
+ if (isServerIsland || !interactive) {
87
+ result.static.push(analyzed);
88
+ } else {
89
+ result.interactive.push(analyzed);
90
+ }
91
+ } catch (err) {
92
+ logger.warn(`Could not analyze ${route.route}: ${err.message}`);
93
+ result.interactive.push({ ...route, interactive: true, features: [] });
94
+ }
95
+ }
96
+
97
+ return result;
98
+ }
99
+
100
+ /**
101
+ * Generate hydration-aware router that skips JS for static routes
102
+ * Key insight: static routes still render HTML, just skip React.hydrate()
103
+ */
104
+ export function generatePartialHydrationCode(routes, analyzedRoutes) {
105
+ const interactivePaths = new Set(
106
+ analyzedRoutes.interactive.map(r => r.route)
107
+ );
108
+
109
+ const imports = routes.map((route, i) => {
110
+ const isInteractive = interactivePaths.has(route.route);
111
+ const componentName = `Page${i}`;
112
+ const importPath = `./pages/${route.file.replace(/\.(jsx|tsx|ts)$/, '.js')}`;
113
+
114
+ // Lazy load static routes (they're just HTML, load fast)
115
+ // Eager load interactive routes (need JS ready)
116
+ return isInteractive
117
+ ? `import ${componentName} from '${importPath}';`
118
+ : `const ${componentName} = React.lazy(() => import('${importPath}'));`;
119
+ }).join('\n');
120
+
121
+ const routeConfigs = routes.map((route, i) => {
122
+ const isInteractive = interactivePaths.has(route.route);
123
+ return ` {
124
+ path: '${route.route}',
125
+ component: Page${i},
126
+ type: '${route.type}',
127
+ hydrate: ${isInteractive},
128
+ lazy: ${!isInteractive}
129
+ }`;
130
+ }).join(',\n');
131
+
132
+ return { imports, routeConfigs };
133
+ }
134
+
135
+ /**
136
+ * Log hydration analysis results
137
+ */
138
+ export function logHydrationReport(analyzedRoutes) {
139
+ const { static: staticRoutes, interactive } = analyzedRoutes;
140
+
141
+ logger.bigLog('HYDRATION ANALYSIS', { color: 'cyan' });
142
+ logger.info(`⚡ Interactive (needs JS): ${interactive.length} routes`);
143
+ logger.info(`🏝️ Static (no JS needed): ${staticRoutes.length} routes`);
144
+
145
+ if (interactive.length > 0) {
146
+ logger.table(interactive.map(r => ({
147
+ route: r.route,
148
+ features: r.features.map(f => f.name).join(', ').substring(0, 40) || 'unknown',
149
+ })));
150
+ }
151
+ }
@@ -0,0 +1,165 @@
1
+ // bertui/src/layouts/index.js
2
+ // Layout system - src/layouts/default.tsx wraps all pages
3
+
4
+ import { join, extname, basename } from 'path';
5
+ import { existsSync, readdirSync, mkdirSync } from 'fs';;
6
+ import logger from '../logger/logger.js';
7
+
8
+ /**
9
+ * Discover all layouts in src/layouts/
10
+ * Layout naming convention:
11
+ * default.tsx → wraps all pages (fallback)
12
+ * blog.tsx → wraps pages in /blog/*
13
+ * [route].tsx → wraps pages matching route prefix
14
+ */
15
+ export async function discoverLayouts(root) {
16
+ const layoutsDir = join(root, 'src', 'layouts');
17
+ const layouts = {};
18
+
19
+ if (!existsSync(layoutsDir)) {
20
+ return layouts;
21
+ }
22
+
23
+ const entries = readdirSync(layoutsDir, { withFileTypes: true });
24
+
25
+ for (const entry of entries) {
26
+ if (!entry.isFile()) continue;
27
+ const ext = extname(entry.name);
28
+ if (!['.jsx', '.tsx', '.js', '.ts'].includes(ext)) continue;
29
+
30
+ const name = basename(entry.name, ext);
31
+ layouts[name] = {
32
+ name,
33
+ path: join(layoutsDir, entry.name),
34
+ route: name === 'default' ? '*' : `/${name}`,
35
+ };
36
+
37
+ logger.debug(`📐 Layout found: ${entry.name} → ${layouts[name].route}`);
38
+ }
39
+
40
+ if (Object.keys(layouts).length > 0) {
41
+ logger.success(`✅ ${Object.keys(layouts).length} layout(s) loaded`);
42
+ }
43
+
44
+ return layouts;
45
+ }
46
+
47
+ /**
48
+ * Match which layout applies to a given route
49
+ * Priority: exact name match > default
50
+ */
51
+ export function matchLayout(route, layouts) {
52
+ if (!layouts || Object.keys(layouts).length === 0) return null;
53
+
54
+ // Strip leading slash and get first segment
55
+ const segment = route.replace(/^\//, '').split('/')[0];
56
+
57
+ // Exact match (e.g., /blog → blog.tsx)
58
+ if (segment && layouts[segment]) {
59
+ return layouts[segment];
60
+ }
61
+
62
+ // Default layout fallback
63
+ if (layouts['default']) {
64
+ return layouts['default'];
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Generate layout wrapper code for the compiler
72
+ * Wraps the page component with the layout component
73
+ */
74
+ export function generateLayoutWrapper(pageImportPath, layoutImportPath, componentName = 'Page') {
75
+ return `
76
+ import React from 'react';
77
+ import ${componentName} from '${pageImportPath}';
78
+ import Layout from '${layoutImportPath}';
79
+
80
+ export default function LayoutWrapped(props) {
81
+ return React.createElement(
82
+ Layout,
83
+ props,
84
+ React.createElement(${componentName}, props)
85
+ );
86
+ }
87
+ `.trim();
88
+ }
89
+
90
+ /**
91
+ * Compile layouts directory - transpiles layout files to .bertui/compiled/layouts/
92
+ */
93
+ export async function compileLayouts(root, compiledDir) {
94
+ const layoutsDir = join(root, 'src', 'layouts');
95
+ if (!existsSync(layoutsDir)) return {};
96
+
97
+ const outDir = join(compiledDir, 'layouts');
98
+
99
+ mkdirSync(outDir, { recursive: true });
100
+
101
+ const layouts = await discoverLayouts(root);
102
+
103
+ for (const [name, layout] of Object.entries(layouts)) {
104
+ const ext = extname(layout.path);
105
+ const loader = ext === '.tsx' ? 'tsx' : ext === '.ts' ? 'ts' : 'jsx';
106
+
107
+ try {
108
+ let code = await Bun.file(layout.path).text();
109
+
110
+ // Add React import if missing
111
+ if (!code.includes('import React')) {
112
+ code = `import React from 'react';\n${code}`;
113
+ }
114
+
115
+ const transpiler = new Bun.Transpiler({
116
+ loader,
117
+ target: 'browser',
118
+ tsconfig: {
119
+ compilerOptions: {
120
+ jsx: 'react',
121
+ jsxFactory: 'React.createElement',
122
+ jsxFragmentFactory: 'React.Fragment',
123
+ },
124
+ },
125
+ });
126
+
127
+ let compiled = await transpiler.transform(code);
128
+
129
+ // Fix relative imports
130
+ compiled = compiled.replace(
131
+ /from\s+['"](\.\.?\/[^'"]+?)(?<!\.js|\.jsx|\.ts|\.tsx|\.json)['"]/g,
132
+ (match, path) => {
133
+ if (path.endsWith('/') || /\.\w+$/.test(path)) return match;
134
+ return `from '${path}.js'`;
135
+ }
136
+ );
137
+
138
+ await Bun.write(join(outDir, `${name}.js`), compiled);
139
+ logger.debug(`📐 Compiled layout: ${name}`);
140
+ } catch (err) {
141
+ logger.error(`Failed to compile layout ${name}: ${err.message}`);
142
+ }
143
+ }
144
+
145
+ return layouts;
146
+ }
147
+
148
+ /**
149
+ * Inject layout into router generation
150
+ * Called by router-generator to wrap page components with their layouts
151
+ */
152
+ export function injectLayoutsIntoRouter(routes, layouts, compiledDir) {
153
+ if (!layouts || Object.keys(layouts).length === 0) return routes;
154
+
155
+ return routes.map(route => {
156
+ const layout = matchLayout(route.route, layouts);
157
+ if (!layout) return route;
158
+
159
+ return {
160
+ ...route,
161
+ layout: layout.name,
162
+ layoutPath: join(compiledDir, 'layouts', `${layout.name}.js`),
163
+ };
164
+ });
165
+ }
@@ -0,0 +1,210 @@
1
+ // bertui/src/loading/index.js
2
+ // Built-in loading states - per route loading UI
3
+
4
+ import { join, extname, basename } from 'path';
5
+ import { existsSync, readdirSync } from 'fs';
6
+ import logger from '../logger/logger.js';
7
+
8
+ /**
9
+ * Default loading spinner HTML injected into pages
10
+ * Beautiful, zero-dependency, CSS-only spinner
11
+ */
12
+ export const DEFAULT_LOADING_HTML = `
13
+ <div id="bertui-loading" style="
14
+ position: fixed;
15
+ top: 0;
16
+ left: 0;
17
+ width: 100%;
18
+ height: 100%;
19
+ background: rgba(255,255,255,0.95);
20
+ display: flex;
21
+ flex-direction: column;
22
+ align-items: center;
23
+ justify-content: center;
24
+ z-index: 99999;
25
+ font-family: system-ui, sans-serif;
26
+ transition: opacity 0.2s ease;
27
+ ">
28
+ <div style="
29
+ width: 40px;
30
+ height: 40px;
31
+ border: 3px solid #e5e7eb;
32
+ border-top-color: #10b981;
33
+ border-radius: 50%;
34
+ animation: bertui-spin 0.7s linear infinite;
35
+ "></div>
36
+ <p style="margin-top: 16px; color: #6b7280; font-size: 14px; font-weight: 500;">Loading...</p>
37
+ </div>
38
+ <style>
39
+ @keyframes bertui-spin {
40
+ to { transform: rotate(360deg); }
41
+ }
42
+ </style>
43
+ <script>
44
+ // Remove loading screen once React mounts
45
+ window.__BERTUI_HIDE_LOADING__ = function() {
46
+ const el = document.getElementById('bertui-loading');
47
+ if (el) {
48
+ el.style.opacity = '0';
49
+ setTimeout(() => el.remove(), 200);
50
+ }
51
+ };
52
+
53
+ // Fallback: remove after 5s no matter what
54
+ setTimeout(() => window.__BERTUI_HIDE_LOADING__?.(), 5000);
55
+
56
+ // React root observer - hide when #root gets children
57
+ const observer = new MutationObserver(() => {
58
+ const root = document.getElementById('root');
59
+ if (root && root.children.length > 0) {
60
+ window.__BERTUI_HIDE_LOADING__?.();
61
+ observer.disconnect();
62
+ }
63
+ });
64
+ const root = document.getElementById('root');
65
+ if (root) observer.observe(root, { childList: true, subtree: true });
66
+ </script>
67
+ `;
68
+
69
+ /**
70
+ * Discover per-route loading components from src/pages/
71
+ * Convention: create a loading.tsx next to your page file
72
+ * e.g., src/pages/blog/loading.tsx → shown while /blog loads
73
+ */
74
+ export async function discoverLoadingComponents(root) {
75
+ const pagesDir = join(root, 'src', 'pages');
76
+ if (!existsSync(pagesDir)) return {};
77
+
78
+ const loadingComponents = {};
79
+
80
+ function scan(dir, routeBase = '') {
81
+ const entries = readdirSync(dir, { withFileTypes: true });
82
+
83
+ for (const entry of entries) {
84
+ const fullPath = join(dir, entry.name);
85
+
86
+ if (entry.isDirectory()) {
87
+ scan(fullPath, `${routeBase}/${entry.name}`);
88
+ } else if (entry.isFile()) {
89
+ const ext = extname(entry.name);
90
+ const name = basename(entry.name, ext);
91
+
92
+ if (name === 'loading' && ['.jsx', '.tsx', '.js', '.ts'].includes(ext)) {
93
+ const route = routeBase || '/';
94
+ loadingComponents[route] = {
95
+ path: fullPath,
96
+ route,
97
+ };
98
+ logger.debug(`⏳ Loading component: ${route} → ${entry.name}`);
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ scan(pagesDir);
105
+
106
+ if (Object.keys(loadingComponents).length > 0) {
107
+ logger.success(`✅ ${Object.keys(loadingComponents).length} loading component(s) found`);
108
+ }
109
+
110
+ return loadingComponents;
111
+ }
112
+
113
+ /**
114
+ * Compile loading components to .bertui/compiled/loading/
115
+ */
116
+ export async function compileLoadingComponents(root, compiledDir) {
117
+ const components = await discoverLoadingComponents(root);
118
+ if (Object.keys(components).length === 0) return components;
119
+
120
+ const outDir = join(compiledDir, 'loading');
121
+ const { mkdirSync } = await import('fs');
122
+ mkdirSync(outDir, { recursive: true });
123
+
124
+ for (const [route, comp] of Object.entries(components)) {
125
+ const ext = extname(comp.path);
126
+ const loader = ext === '.tsx' ? 'tsx' : ext === '.ts' ? 'ts' : 'jsx';
127
+ const safeName = route.replace(/\//g, '_').replace(/^_/, '') || 'root';
128
+
129
+ try {
130
+ let code = await Bun.file(comp.path).text();
131
+
132
+ if (!code.includes('import React')) {
133
+ code = `import React from 'react';\n${code}`;
134
+ }
135
+
136
+ const transpiler = new Bun.Transpiler({
137
+ loader,
138
+ target: 'browser',
139
+ tsconfig: {
140
+ compilerOptions: {
141
+ jsx: 'react',
142
+ jsxFactory: 'React.createElement',
143
+ jsxFragmentFactory: 'React.Fragment',
144
+ },
145
+ },
146
+ });
147
+
148
+ const compiled = await transpiler.transform(code);
149
+ await Bun.write(join(outDir, `${safeName}.js`), compiled);
150
+ components[route].compiledPath = join(outDir, `${safeName}.js`);
151
+ components[route].compiledName = safeName;
152
+
153
+ } catch (err) {
154
+ logger.error(`Failed to compile loading component for ${route}: ${err.message}`);
155
+ }
156
+ }
157
+
158
+ return components;
159
+ }
160
+
161
+ /**
162
+ * Generate loading-aware router code
163
+ * Wraps each route component with Suspense + loading fallback
164
+ */
165
+ export function generateLoadingAwareRouter(routes, loadingComponents) {
166
+ const hasLoading = Object.keys(loadingComponents).length > 0;
167
+
168
+ const loadingImports = hasLoading
169
+ ? Object.entries(loadingComponents)
170
+ .map(([route, comp]) => {
171
+ const safeName = comp.compiledName || (route.replace(/\//g, '_').replace(/^_/, '') || 'root');
172
+ return `import Loading_${safeName} from './loading/${safeName}.js';`;
173
+ })
174
+ .join('\n')
175
+ : '';
176
+
177
+ const getLoadingComponent = (route) => {
178
+ // Exact match
179
+ if (loadingComponents[route]) {
180
+ const safeName = loadingComponents[route].compiledName ||
181
+ (route.replace(/\//g, '_').replace(/^_/, '') || 'root');
182
+ return `Loading_${safeName}`;
183
+ }
184
+
185
+ // Parent route match
186
+ const segments = route.split('/').filter(Boolean);
187
+ while (segments.length > 0) {
188
+ segments.pop();
189
+ const parent = '/' + segments.join('/') || '/';
190
+ if (loadingComponents[parent]) {
191
+ const safeName = loadingComponents[parent].compiledName ||
192
+ (parent.replace(/\//g, '_').replace(/^_/, '') || 'root');
193
+ return `Loading_${safeName}`;
194
+ }
195
+ }
196
+
197
+ return null;
198
+ };
199
+
200
+ return { loadingImports, getLoadingComponent };
201
+ }
202
+
203
+ /**
204
+ * Generate the default loading screen script to inject into HTML
205
+ */
206
+ export function getLoadingScript(customText = 'Loading...', color = '#10b981') {
207
+ return DEFAULT_LOADING_HTML
208
+ .replace('Loading...', customText)
209
+ .replace('#10b981', color);
210
+ }
@@ -0,0 +1,182 @@
1
+ // bertui/src/middleware/index.js
2
+ // Middleware system - src/middleware.ts runs before every request
3
+
4
+ import { join, extname } from 'path';
5
+ import { existsSync } from 'fs';
6
+ import logger from '../logger/logger.js';
7
+
8
+ /**
9
+ * Middleware context passed to every middleware function
10
+ */
11
+ export class MiddlewareContext {
12
+ constructor(request, options = {}) {
13
+ this.request = request;
14
+ this.url = new URL(request.url);
15
+ this.pathname = this.url.pathname;
16
+ this.method = request.method;
17
+ this.headers = Object.fromEntries(request.headers.entries());
18
+ this.params = options.params || {};
19
+ this.route = options.route || null;
20
+ this._response = null;
21
+ this._redirectTo = null;
22
+ this._stopped = false;
23
+ this.locals = {}; // Share data between middlewares and pages
24
+ }
25
+
26
+ /** Respond early - stops further processing */
27
+ respond(body, init = {}) {
28
+ this._response = new Response(body, {
29
+ status: init.status || 200,
30
+ headers: {
31
+ 'Content-Type': 'text/html',
32
+ ...init.headers
33
+ }
34
+ });
35
+ this._stopped = true;
36
+ }
37
+
38
+ /** Redirect to another URL */
39
+ redirect(url, status = 302) {
40
+ this._redirectTo = url;
41
+ this._response = Response.redirect(url, status);
42
+ this._stopped = true;
43
+ }
44
+
45
+ /** Set a response header (added to final response) */
46
+ setHeader(key, value) {
47
+ if (!this._extraHeaders) this._extraHeaders = {};
48
+ this._extraHeaders[key] = value;
49
+ }
50
+
51
+ /** Check if middleware stopped the chain */
52
+ get stopped() {
53
+ return this._stopped;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Load and run user middleware from src/middleware.ts or src/middleware.js
59
+ */
60
+ export async function loadMiddleware(root) {
61
+ const candidates = [
62
+ join(root, 'src', 'middleware.ts'),
63
+ join(root, 'src', 'middleware.tsx'),
64
+ join(root, 'src', 'middleware.js'),
65
+ ];
66
+
67
+ for (const path of candidates) {
68
+ if (existsSync(path)) {
69
+ try {
70
+ // Transpile if TypeScript
71
+ const ext = extname(path);
72
+ let code = await Bun.file(path).text();
73
+
74
+ if (ext === '.ts' || ext === '.tsx') {
75
+ const transpiler = new Bun.Transpiler({
76
+ loader: ext === '.tsx' ? 'tsx' : 'ts',
77
+ target: 'bun',
78
+ });
79
+ code = await transpiler.transform(code);
80
+ }
81
+
82
+ // Write to temp file and import
83
+ const tmpPath = join(root, '.bertui', 'middleware.js');
84
+ await Bun.write(tmpPath, code);
85
+
86
+ const mod = await import(`${tmpPath}?t=${Date.now()}`);
87
+ logger.success('✅ Middleware loaded: ' + path.replace(root, ''));
88
+
89
+ return {
90
+ default: mod.default || null,
91
+ onRequest: mod.onRequest || mod.default || null,
92
+ onResponse: mod.onResponse || null,
93
+ onError: mod.onError || null,
94
+ };
95
+ } catch (err) {
96
+ logger.error(`Failed to load middleware: ${err.message}`);
97
+ return null;
98
+ }
99
+ }
100
+ }
101
+
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Run middleware chain for a request
107
+ * Returns a Response if middleware intercepted, null to continue
108
+ */
109
+ export async function runMiddleware(middlewareMod, request, routeInfo = {}) {
110
+ if (!middlewareMod) return null;
111
+
112
+ const ctx = new MiddlewareContext(request, routeInfo);
113
+
114
+ try {
115
+ // Run onRequest middleware
116
+ if (middlewareMod.onRequest) {
117
+ await middlewareMod.onRequest(ctx);
118
+ if (ctx.stopped) {
119
+ logger.debug(`🛡️ Middleware intercepted: ${ctx.pathname}`);
120
+ return ctx._response;
121
+ }
122
+ }
123
+
124
+ return null; // Continue to route handler
125
+ } catch (err) {
126
+ logger.error(`Middleware error: ${err.message}`);
127
+
128
+ // Run error handler if defined
129
+ if (middlewareMod.onError) {
130
+ try {
131
+ await middlewareMod.onError(ctx, err);
132
+ if (ctx._response) return ctx._response;
133
+ } catch (e) {
134
+ logger.error(`Middleware error handler failed: ${e.message}`);
135
+ }
136
+ }
137
+
138
+ return null;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * MiddlewareManager - watches and reloads middleware on change
144
+ */
145
+ export class MiddlewareManager {
146
+ constructor(root) {
147
+ this.root = root;
148
+ this.middleware = null;
149
+ this.watcher = null;
150
+ }
151
+
152
+ async load() {
153
+ this.middleware = await loadMiddleware(this.root);
154
+ return this;
155
+ }
156
+
157
+ async run(request, routeInfo = {}) {
158
+ return runMiddleware(this.middleware, request, routeInfo);
159
+ }
160
+
161
+ watch() {
162
+ const candidates = [
163
+ join(this.root, 'src', 'middleware.ts'),
164
+ join(this.root, 'src', 'middleware.tsx'),
165
+ join(this.root, 'src', 'middleware.js'),
166
+ ];
167
+
168
+ const existing = candidates.find(existsSync);
169
+ if (!existing) return;
170
+
171
+ const { watch } = require('fs');
172
+ this.watcher = watch(existing, async () => {
173
+ logger.info('🔄 Reloading middleware...');
174
+ this.middleware = await loadMiddleware(this.root);
175
+ logger.success('✅ Middleware reloaded');
176
+ });
177
+ }
178
+
179
+ dispose() {
180
+ if (this.watcher) this.watcher.close();
181
+ }
182
+ }