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.
- package/index.js +65 -45
- package/package.json +5 -2
- package/src/analyzer/index.js +370 -0
- package/src/build/compiler/route-discoverer.js +2 -0
- package/src/build/processors/css-builder.js +116 -80
- package/src/build.js +104 -93
- package/src/cli.js +83 -18
- package/src/client/compiler.js +168 -67
- package/src/css/processor.js +46 -1
- package/src/dev.js +47 -9
- package/src/hydration/index.js +151 -0
- package/src/layouts/index.js +165 -0
- package/src/loading/index.js +210 -0
- package/src/middleware/index.js +182 -0
- package/src/scaffolder/index.js +310 -0
- package/src/serve.js +195 -0
- package/src/server/dev-handler.js +78 -148
- package/src/server/dev-server-utils.js +16 -5
- package/src/server-islands/index.js +1 -1
- package/src/utils/cache.js +297 -0
|
@@ -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
|
+
}
|