@buenojs/bueno 0.8.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/.env.example +109 -0
- package/.github/workflows/ci.yml +31 -0
- package/LICENSE +21 -0
- package/README.md +892 -0
- package/architecture.md +652 -0
- package/bun.lock +70 -0
- package/dist/cli/index.js +3233 -0
- package/dist/index.js +9014 -0
- package/package.json +77 -0
- package/src/cache/index.ts +795 -0
- package/src/cli/ARCHITECTURE.md +837 -0
- package/src/cli/bin.ts +10 -0
- package/src/cli/commands/build.ts +425 -0
- package/src/cli/commands/dev.ts +248 -0
- package/src/cli/commands/generate.ts +541 -0
- package/src/cli/commands/help.ts +55 -0
- package/src/cli/commands/index.ts +112 -0
- package/src/cli/commands/migration.ts +355 -0
- package/src/cli/commands/new.ts +804 -0
- package/src/cli/commands/start.ts +208 -0
- package/src/cli/core/args.ts +283 -0
- package/src/cli/core/console.ts +349 -0
- package/src/cli/core/index.ts +60 -0
- package/src/cli/core/prompt.ts +424 -0
- package/src/cli/core/spinner.ts +265 -0
- package/src/cli/index.ts +135 -0
- package/src/cli/templates/deploy.ts +295 -0
- package/src/cli/templates/docker.ts +307 -0
- package/src/cli/templates/index.ts +24 -0
- package/src/cli/utils/fs.ts +428 -0
- package/src/cli/utils/index.ts +8 -0
- package/src/cli/utils/strings.ts +197 -0
- package/src/config/env.ts +408 -0
- package/src/config/index.ts +506 -0
- package/src/config/loader.ts +329 -0
- package/src/config/merge.ts +285 -0
- package/src/config/types.ts +320 -0
- package/src/config/validation.ts +441 -0
- package/src/container/forward-ref.ts +143 -0
- package/src/container/index.ts +386 -0
- package/src/context/index.ts +360 -0
- package/src/database/index.ts +1142 -0
- package/src/database/migrations/index.ts +371 -0
- package/src/database/schema/index.ts +619 -0
- package/src/frontend/api-routes.ts +640 -0
- package/src/frontend/bundler.ts +643 -0
- package/src/frontend/console-client.ts +419 -0
- package/src/frontend/console-stream.ts +587 -0
- package/src/frontend/dev-server.ts +846 -0
- package/src/frontend/file-router.ts +611 -0
- package/src/frontend/frameworks/index.ts +106 -0
- package/src/frontend/frameworks/react.ts +85 -0
- package/src/frontend/frameworks/solid.ts +104 -0
- package/src/frontend/frameworks/svelte.ts +110 -0
- package/src/frontend/frameworks/vue.ts +92 -0
- package/src/frontend/hmr-client.ts +663 -0
- package/src/frontend/hmr.ts +728 -0
- package/src/frontend/index.ts +342 -0
- package/src/frontend/islands.ts +552 -0
- package/src/frontend/isr.ts +555 -0
- package/src/frontend/layout.ts +475 -0
- package/src/frontend/ssr/react.ts +446 -0
- package/src/frontend/ssr/solid.ts +523 -0
- package/src/frontend/ssr/svelte.ts +546 -0
- package/src/frontend/ssr/vue.ts +504 -0
- package/src/frontend/ssr.ts +699 -0
- package/src/frontend/types.ts +2274 -0
- package/src/health/index.ts +604 -0
- package/src/index.ts +410 -0
- package/src/lock/index.ts +587 -0
- package/src/logger/index.ts +444 -0
- package/src/logger/transports/index.ts +969 -0
- package/src/metrics/index.ts +494 -0
- package/src/middleware/built-in.ts +360 -0
- package/src/middleware/index.ts +94 -0
- package/src/modules/filters.ts +458 -0
- package/src/modules/guards.ts +405 -0
- package/src/modules/index.ts +1256 -0
- package/src/modules/interceptors.ts +574 -0
- package/src/modules/lazy.ts +418 -0
- package/src/modules/lifecycle.ts +478 -0
- package/src/modules/metadata.ts +90 -0
- package/src/modules/pipes.ts +626 -0
- package/src/router/index.ts +339 -0
- package/src/router/linear.ts +371 -0
- package/src/router/regex.ts +292 -0
- package/src/router/tree.ts +562 -0
- package/src/rpc/index.ts +1263 -0
- package/src/security/index.ts +436 -0
- package/src/ssg/index.ts +631 -0
- package/src/storage/index.ts +456 -0
- package/src/telemetry/index.ts +1097 -0
- package/src/testing/index.ts +1586 -0
- package/src/types/index.ts +236 -0
- package/src/types/optional-deps.d.ts +219 -0
- package/src/validation/index.ts +276 -0
- package/src/websocket/index.ts +1004 -0
- package/tests/integration/cli.test.ts +1016 -0
- package/tests/integration/fullstack.test.ts +234 -0
- package/tests/unit/cache.test.ts +174 -0
- package/tests/unit/cli-commands.test.ts +892 -0
- package/tests/unit/cli.test.ts +1258 -0
- package/tests/unit/container.test.ts +279 -0
- package/tests/unit/context.test.ts +221 -0
- package/tests/unit/database.test.ts +183 -0
- package/tests/unit/linear-router.test.ts +280 -0
- package/tests/unit/lock.test.ts +336 -0
- package/tests/unit/middleware.test.ts +184 -0
- package/tests/unit/modules.test.ts +142 -0
- package/tests/unit/pubsub.test.ts +257 -0
- package/tests/unit/regex-router.test.ts +265 -0
- package/tests/unit/router.test.ts +373 -0
- package/tests/unit/rpc.test.ts +1248 -0
- package/tests/unit/security.test.ts +174 -0
- package/tests/unit/telemetry.test.ts +371 -0
- package/tests/unit/test-cache.test.ts +110 -0
- package/tests/unit/test-database.test.ts +282 -0
- package/tests/unit/tree-router.test.ts +325 -0
- package/tests/unit/validation.test.ts +794 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Side Rendering (SSR) Implementation
|
|
3
|
+
*
|
|
4
|
+
* Provides a unified SSR system that supports:
|
|
5
|
+
* - React, Vue, Svelte, and Solid frameworks
|
|
6
|
+
* - Streaming SSR for faster TTFB
|
|
7
|
+
* - Client-side hydration
|
|
8
|
+
* - Server-side data fetching
|
|
9
|
+
* - Head management (title, meta, links)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createLogger, type Logger } from "../logger/index.js";
|
|
13
|
+
import type {
|
|
14
|
+
SSRConfig,
|
|
15
|
+
PartialSSRConfig,
|
|
16
|
+
SSRContext,
|
|
17
|
+
SSRElement,
|
|
18
|
+
SSRPage,
|
|
19
|
+
RenderResult,
|
|
20
|
+
SSRHydrationData,
|
|
21
|
+
SSRRenderOptions,
|
|
22
|
+
BuildManifest,
|
|
23
|
+
FrontendFramework,
|
|
24
|
+
FrameworkSSRRenderer,
|
|
25
|
+
PreloadLink,
|
|
26
|
+
} from "./types.js";
|
|
27
|
+
import { createReactSSRRenderer, type ReactSSRRenderer } from "./ssr/react.js";
|
|
28
|
+
import { createVueSSRRenderer, type VueSSRRenderer } from "./ssr/vue.js";
|
|
29
|
+
import { createSvelteSSRRenderer, type SvelteSSRRenderer } from "./ssr/svelte.js";
|
|
30
|
+
import { createSolidSSRRenderer, type SolidSSRRenderer } from "./ssr/solid.js";
|
|
31
|
+
|
|
32
|
+
// ============= Constants =============
|
|
33
|
+
|
|
34
|
+
const DEFAULT_MAX_TIMEOUT = 5000;
|
|
35
|
+
const DEFAULT_STREAMING = true;
|
|
36
|
+
const DEFAULT_BUFFER_INITIAL_STREAM = true;
|
|
37
|
+
|
|
38
|
+
// ============= SSR Renderer Class =============
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Main SSR Renderer class
|
|
42
|
+
*
|
|
43
|
+
* Provides server-side rendering for all supported frameworks
|
|
44
|
+
* with streaming support and client hydration.
|
|
45
|
+
*/
|
|
46
|
+
export class SSRRenderer {
|
|
47
|
+
private config: SSRConfig;
|
|
48
|
+
private logger: Logger;
|
|
49
|
+
private frameworkRenderer: FrameworkSSRRenderer | null = null;
|
|
50
|
+
private pageCache: Map<string, SSRPage> = new Map();
|
|
51
|
+
|
|
52
|
+
constructor(config: PartialSSRConfig) {
|
|
53
|
+
this.config = this.normalizeConfig(config);
|
|
54
|
+
this.logger = createLogger({
|
|
55
|
+
level: "debug",
|
|
56
|
+
pretty: true,
|
|
57
|
+
context: { component: "SSRRenderer" },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Normalize partial config to full config with defaults
|
|
63
|
+
*/
|
|
64
|
+
private normalizeConfig(config: PartialSSRConfig): SSRConfig {
|
|
65
|
+
return {
|
|
66
|
+
entry: config.entry,
|
|
67
|
+
clientEntry: config.clientEntry,
|
|
68
|
+
clientManifest: config.clientManifest,
|
|
69
|
+
streaming: config.streaming ?? DEFAULT_STREAMING,
|
|
70
|
+
maxTimeout: config.maxTimeout ?? DEFAULT_MAX_TIMEOUT,
|
|
71
|
+
bufferInitialStream: config.bufferInitialStream ?? DEFAULT_BUFFER_INITIAL_STREAM,
|
|
72
|
+
framework: config.framework,
|
|
73
|
+
rootDir: config.rootDir,
|
|
74
|
+
template: config.template,
|
|
75
|
+
templateFn: config.templateFn,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Initialize the framework-specific renderer
|
|
81
|
+
*/
|
|
82
|
+
async init(): Promise<void> {
|
|
83
|
+
this.frameworkRenderer = await this.createFrameworkRenderer(this.config.framework);
|
|
84
|
+
this.logger.info(`SSR initialized for framework: ${this.config.framework}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create framework-specific renderer
|
|
89
|
+
*/
|
|
90
|
+
private async createFrameworkRenderer(framework: FrontendFramework): Promise<FrameworkSSRRenderer> {
|
|
91
|
+
switch (framework) {
|
|
92
|
+
case "react":
|
|
93
|
+
const reactRenderer = createReactSSRRenderer();
|
|
94
|
+
await reactRenderer.init();
|
|
95
|
+
return reactRenderer;
|
|
96
|
+
case "vue":
|
|
97
|
+
const vueRenderer = createVueSSRRenderer();
|
|
98
|
+
await vueRenderer.init();
|
|
99
|
+
return vueRenderer;
|
|
100
|
+
case "svelte":
|
|
101
|
+
const svelteRenderer = createSvelteSSRRenderer();
|
|
102
|
+
await svelteRenderer.init();
|
|
103
|
+
return svelteRenderer;
|
|
104
|
+
case "solid":
|
|
105
|
+
const solidRenderer = createSolidSSRRenderer();
|
|
106
|
+
await solidRenderer.init();
|
|
107
|
+
return solidRenderer;
|
|
108
|
+
default:
|
|
109
|
+
throw new Error(`Unsupported framework: ${framework}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Render a page to HTML
|
|
115
|
+
*/
|
|
116
|
+
async render(url: string, request: Request): Promise<RenderResult> {
|
|
117
|
+
if (!this.frameworkRenderer) {
|
|
118
|
+
await this.init();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const context = this.createContext(url, request);
|
|
122
|
+
const startTime = Date.now();
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// Load the page module
|
|
126
|
+
const page = await this.loadPage(url);
|
|
127
|
+
|
|
128
|
+
// Run server-side data fetching if available
|
|
129
|
+
if (page.getServerSideProps) {
|
|
130
|
+
this.logger.debug(`Fetching server-side props for: ${url}`);
|
|
131
|
+
const props = await page.getServerSideProps(context);
|
|
132
|
+
context.data = { ...context.data, ...props };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Render the page
|
|
136
|
+
const component = this.frameworkRenderer!.createComponent(page, context);
|
|
137
|
+
const html = await this.frameworkRenderer!.renderToString(component, context);
|
|
138
|
+
|
|
139
|
+
// Get head elements
|
|
140
|
+
const headElements = this.frameworkRenderer!.getHeadElements(context);
|
|
141
|
+
const head = this.renderHeadElements(headElements);
|
|
142
|
+
|
|
143
|
+
// Collect loaded modules
|
|
144
|
+
const modules = Array.from(context.modules);
|
|
145
|
+
|
|
146
|
+
const duration = Date.now() - startTime;
|
|
147
|
+
this.logger.debug(`Rendered ${url} in ${duration}ms`);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
html,
|
|
151
|
+
head,
|
|
152
|
+
body: "",
|
|
153
|
+
data: context.data,
|
|
154
|
+
modules,
|
|
155
|
+
status: context.status,
|
|
156
|
+
};
|
|
157
|
+
} catch (error) {
|
|
158
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
159
|
+
this.logger.error(`SSR render failed for ${url}:`, error);
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
html: this.renderErrorPage(error),
|
|
163
|
+
head: "<title>Error</title>",
|
|
164
|
+
body: "",
|
|
165
|
+
data: {},
|
|
166
|
+
modules: [],
|
|
167
|
+
status: 500,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Render a page to a stream
|
|
174
|
+
*/
|
|
175
|
+
renderToStream(url: string, request: Request): ReadableStream<Uint8Array> {
|
|
176
|
+
const encoder = new TextEncoder();
|
|
177
|
+
let context: SSRContext;
|
|
178
|
+
let frameworkRenderer = this.frameworkRenderer;
|
|
179
|
+
|
|
180
|
+
return new ReadableStream<Uint8Array>({
|
|
181
|
+
start: async (controller) => {
|
|
182
|
+
try {
|
|
183
|
+
if (!frameworkRenderer) {
|
|
184
|
+
await this.init();
|
|
185
|
+
frameworkRenderer = this.frameworkRenderer;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
context = this.createContext(url, request);
|
|
189
|
+
|
|
190
|
+
// Load the page module
|
|
191
|
+
const page = await this.loadPage(url);
|
|
192
|
+
|
|
193
|
+
// Run server-side data fetching if available
|
|
194
|
+
if (page.getServerSideProps) {
|
|
195
|
+
const props = await page.getServerSideProps(context);
|
|
196
|
+
context.data = { ...context.data, ...props };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Send HTML preamble
|
|
200
|
+
const preamble = this.renderPreamble(context);
|
|
201
|
+
controller.enqueue(encoder.encode(preamble));
|
|
202
|
+
|
|
203
|
+
// Create component and render to stream
|
|
204
|
+
const component = frameworkRenderer!.createComponent(page, context);
|
|
205
|
+
const htmlStream = frameworkRenderer!.renderToStream(component, context);
|
|
206
|
+
|
|
207
|
+
const reader = htmlStream.getReader();
|
|
208
|
+
while (true) {
|
|
209
|
+
const { done, value } = await reader.read();
|
|
210
|
+
if (done) break;
|
|
211
|
+
controller.enqueue(value);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Send HTML footer with hydration data
|
|
215
|
+
const footer = this.renderFooter(context);
|
|
216
|
+
controller.enqueue(encoder.encode(footer));
|
|
217
|
+
controller.close();
|
|
218
|
+
} catch (error) {
|
|
219
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
220
|
+
this.logger.error(`SSR stream failed for ${url}:`, error);
|
|
221
|
+
controller.enqueue(encoder.encode(this.renderErrorPage(error)));
|
|
222
|
+
controller.close();
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Render with options
|
|
230
|
+
*/
|
|
231
|
+
async renderWithOptions(options: SSRRenderOptions): Promise<RenderResult> {
|
|
232
|
+
const { url, request, params = {}, props = {}, skipStreaming = false } = options;
|
|
233
|
+
|
|
234
|
+
if (!skipStreaming && this.config.streaming) {
|
|
235
|
+
// For streaming, we need to buffer the result
|
|
236
|
+
const stream = this.renderToStream(url, request);
|
|
237
|
+
const reader = stream.getReader();
|
|
238
|
+
const chunks: Uint8Array[] = [];
|
|
239
|
+
|
|
240
|
+
while (true) {
|
|
241
|
+
const { done, value } = await reader.read();
|
|
242
|
+
if (done) break;
|
|
243
|
+
chunks.push(value);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const html = new TextDecoder().decode(
|
|
247
|
+
chunks.reduce((acc, chunk) => {
|
|
248
|
+
const combined = new Uint8Array(acc.length + chunk.length);
|
|
249
|
+
combined.set(acc);
|
|
250
|
+
combined.set(chunk, acc.length);
|
|
251
|
+
return combined;
|
|
252
|
+
}, new Uint8Array())
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
html,
|
|
257
|
+
head: "",
|
|
258
|
+
body: "",
|
|
259
|
+
data: props,
|
|
260
|
+
modules: [],
|
|
261
|
+
status: 200,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return this.render(url, request);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Get client hydration script
|
|
270
|
+
*/
|
|
271
|
+
getHydrationScript(): string {
|
|
272
|
+
const framework = this.config.framework;
|
|
273
|
+
const clientEntry = this.config.clientEntry;
|
|
274
|
+
|
|
275
|
+
// Framework-specific hydration code
|
|
276
|
+
const hydrationScripts: Record<FrontendFramework, string> = {
|
|
277
|
+
react: `
|
|
278
|
+
(function() {
|
|
279
|
+
const data = JSON.parse(document.getElementById('__DATA__').textContent);
|
|
280
|
+
window.__SSR_DATA__ = data;
|
|
281
|
+
import('${clientEntry}').then(({ hydrate }) => {
|
|
282
|
+
hydrate(document.getElementById('app'), data);
|
|
283
|
+
});
|
|
284
|
+
})();`,
|
|
285
|
+
vue: `
|
|
286
|
+
(function() {
|
|
287
|
+
const data = JSON.parse(document.getElementById('__DATA__').textContent);
|
|
288
|
+
window.__SSR_DATA__ = data;
|
|
289
|
+
import('${clientEntry}').then(({ createApp }) => {
|
|
290
|
+
createApp(data).mount('#app', true);
|
|
291
|
+
});
|
|
292
|
+
})();`,
|
|
293
|
+
svelte: `
|
|
294
|
+
(function() {
|
|
295
|
+
const data = JSON.parse(document.getElementById('__DATA__').textContent);
|
|
296
|
+
window.__SSR_DATA__ = data;
|
|
297
|
+
import('${clientEntry}').then(({ mount }) => {
|
|
298
|
+
mount(document.getElementById('app'), { props: data.props, hydrate: true });
|
|
299
|
+
});
|
|
300
|
+
})();`,
|
|
301
|
+
solid: `
|
|
302
|
+
(function() {
|
|
303
|
+
const data = JSON.parse(document.getElementById('__DATA__').textContent);
|
|
304
|
+
window.__SSR_DATA__ = data;
|
|
305
|
+
import('${clientEntry}').then({ hydrate } => {
|
|
306
|
+
hydrate(document.getElementById('app'));
|
|
307
|
+
});
|
|
308
|
+
})();`,
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
return `<script type="module">${hydrationScripts[framework]}</script>`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Get preload links for modules
|
|
316
|
+
*/
|
|
317
|
+
getPreloadLinks(modules: string[]): string {
|
|
318
|
+
const manifest = this.config.clientManifest;
|
|
319
|
+
const links: string[] = [];
|
|
320
|
+
const seen = new Set<string>();
|
|
321
|
+
|
|
322
|
+
for (const module of modules) {
|
|
323
|
+
if (seen.has(module)) continue;
|
|
324
|
+
seen.add(module);
|
|
325
|
+
|
|
326
|
+
const fileInfo = manifest.files[module];
|
|
327
|
+
if (!fileInfo) continue;
|
|
328
|
+
|
|
329
|
+
if (fileInfo.type === "js") {
|
|
330
|
+
links.push(`<link rel="modulepreload" href="/${module}">`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Also preload dependencies
|
|
334
|
+
if (fileInfo.imports) {
|
|
335
|
+
for (const dep of fileInfo.imports) {
|
|
336
|
+
if (!seen.has(dep)) {
|
|
337
|
+
links.push(`<link rel="modulepreload" href="/${dep}">`);
|
|
338
|
+
seen.add(dep);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return links.join("\n");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Get CSS links for modules
|
|
349
|
+
*/
|
|
350
|
+
getCSSLinks(modules: string[]): string {
|
|
351
|
+
const manifest = this.config.clientManifest;
|
|
352
|
+
const links: string[] = [];
|
|
353
|
+
const seen = new Set<string>();
|
|
354
|
+
|
|
355
|
+
// Get CSS for each entry point
|
|
356
|
+
for (const [entry, cssFiles] of Object.entries(manifest.css)) {
|
|
357
|
+
for (const cssFile of cssFiles) {
|
|
358
|
+
if (!seen.has(cssFile)) {
|
|
359
|
+
links.push(`<link rel="stylesheet" href="/${cssFile}">`);
|
|
360
|
+
seen.add(cssFile);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Also check module-specific CSS
|
|
366
|
+
for (const module of modules) {
|
|
367
|
+
const fileInfo = manifest.files[module];
|
|
368
|
+
if (fileInfo?.type === "css" && !seen.has(module)) {
|
|
369
|
+
links.push(`<link rel="stylesheet" href="/${module}">`);
|
|
370
|
+
seen.add(module);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return links.join("\n");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Create SSR context
|
|
379
|
+
*/
|
|
380
|
+
private createContext(url: string, request: Request): SSRContext {
|
|
381
|
+
const parsedUrl = new URL(url, request.url);
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
url: parsedUrl.href,
|
|
385
|
+
request,
|
|
386
|
+
headers: new Headers(),
|
|
387
|
+
status: 200,
|
|
388
|
+
head: [],
|
|
389
|
+
body: [],
|
|
390
|
+
data: {},
|
|
391
|
+
modules: new Set(),
|
|
392
|
+
pathname: parsedUrl.pathname,
|
|
393
|
+
query: parsedUrl.searchParams,
|
|
394
|
+
params: {},
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Load page module
|
|
400
|
+
*/
|
|
401
|
+
private async loadPage(url: string): Promise<SSRPage> {
|
|
402
|
+
// Check cache first
|
|
403
|
+
const cached = this.pageCache.get(url);
|
|
404
|
+
if (cached) return cached;
|
|
405
|
+
|
|
406
|
+
// Dynamic import of the server entry
|
|
407
|
+
try {
|
|
408
|
+
const entryPath = this.config.entry.startsWith("/")
|
|
409
|
+
? this.config.entry
|
|
410
|
+
: `${this.config.rootDir || "."}/${this.config.entry}`;
|
|
411
|
+
|
|
412
|
+
const module = await import(entryPath);
|
|
413
|
+
const page: SSRPage = module.default || module;
|
|
414
|
+
|
|
415
|
+
// Cache for future requests
|
|
416
|
+
this.pageCache.set(url, page);
|
|
417
|
+
|
|
418
|
+
return page;
|
|
419
|
+
} catch (error) {
|
|
420
|
+
this.logger.error(`Failed to load page: ${url}`, error);
|
|
421
|
+
throw new Error(`Page not found: ${url}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Render head elements to string
|
|
427
|
+
*/
|
|
428
|
+
private renderHeadElements(elements: SSRElement[]): string {
|
|
429
|
+
return elements.map(this.ssrElementToString).join("\n");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Convert SSR element to HTML string
|
|
434
|
+
*/
|
|
435
|
+
private ssrElementToString(element: SSRElement): string {
|
|
436
|
+
if (element.tag === "#text") {
|
|
437
|
+
return this.escapeHtml(element.innerHTML || "");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const attrs = Object.entries(element.attrs)
|
|
441
|
+
.map(([key, value]) => `${key}="${this.escapeHtml(value)}"`)
|
|
442
|
+
.join(" ");
|
|
443
|
+
|
|
444
|
+
const openTag = attrs ? `<${element.tag} ${attrs}>` : `<${element.tag}>`;
|
|
445
|
+
|
|
446
|
+
if (element.innerHTML) {
|
|
447
|
+
return `${openTag}${element.innerHTML}</${element.tag}>`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (element.children && element.children.length > 0) {
|
|
451
|
+
const children = element.children.map(this.ssrElementToString.bind(this)).join("");
|
|
452
|
+
return `${openTag}${children}</${element.tag}>`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Self-closing tags
|
|
456
|
+
const voidElements = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"];
|
|
457
|
+
if (voidElements.includes(element.tag)) {
|
|
458
|
+
return attrs ? `<${element.tag} ${attrs}>` : `<${element.tag}>`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return `${openTag}</${element.tag}>`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Escape HTML special characters
|
|
466
|
+
*/
|
|
467
|
+
private escapeHtml(str: string): string {
|
|
468
|
+
return str
|
|
469
|
+
.replace(/&/g, "\x26amp;")
|
|
470
|
+
.replace(/</g, "\x26lt;")
|
|
471
|
+
.replace(/>/g, "\x26gt;")
|
|
472
|
+
.replace(/"/g, "\x26quot;")
|
|
473
|
+
.replace(/'/g, "'");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Render HTML preamble (opening tags)
|
|
478
|
+
*/
|
|
479
|
+
private renderPreamble(context: SSRContext): string {
|
|
480
|
+
const manifest = this.config.clientManifest;
|
|
481
|
+
const preloadLinks = this.getPreloadLinks([]);
|
|
482
|
+
const cssLinks = this.getCSSLinks([]);
|
|
483
|
+
|
|
484
|
+
return `<!DOCTYPE html>
|
|
485
|
+
<html>
|
|
486
|
+
<head>
|
|
487
|
+
<meta charset="utf-8">
|
|
488
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
489
|
+
${preloadLinks}
|
|
490
|
+
${cssLinks}
|
|
491
|
+
</head>
|
|
492
|
+
<body>
|
|
493
|
+
<div id="app">`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Render HTML footer (closing tags and scripts)
|
|
498
|
+
*/
|
|
499
|
+
private renderFooter(context: SSRContext): string {
|
|
500
|
+
const hydrationData: SSRHydrationData = {
|
|
501
|
+
props: context.data,
|
|
502
|
+
url: context.url,
|
|
503
|
+
params: context.params,
|
|
504
|
+
query: Object.fromEntries(context.query),
|
|
505
|
+
framework: this.config.framework,
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const dataScript = `<script type="application/json" id="__DATA__">${JSON.stringify(hydrationData)}</script>`;
|
|
509
|
+
const hydrationScript = this.getHydrationScript();
|
|
510
|
+
|
|
511
|
+
return `</div>
|
|
512
|
+
${dataScript}
|
|
513
|
+
${hydrationScript}
|
|
514
|
+
</body>
|
|
515
|
+
</html>`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Render error page
|
|
520
|
+
*/
|
|
521
|
+
private renderErrorPage(error: unknown): string {
|
|
522
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
523
|
+
const stack = error instanceof Error && process.env.NODE_ENV !== "production"
|
|
524
|
+
? `<pre>${this.escapeHtml(error.stack || "")}</pre>`
|
|
525
|
+
: "";
|
|
526
|
+
|
|
527
|
+
return `<!DOCTYPE html>
|
|
528
|
+
<html>
|
|
529
|
+
<head>
|
|
530
|
+
<title>Error</title>
|
|
531
|
+
<style>
|
|
532
|
+
body { font-family: system-ui, sans-serif; padding: 2rem; }
|
|
533
|
+
h1 { color: #dc2626; }
|
|
534
|
+
pre { background: #f5f5f5; padding: 1rem; overflow: auto; }
|
|
535
|
+
</style>
|
|
536
|
+
</head>
|
|
537
|
+
<body>
|
|
538
|
+
<h1>Server Error</h1>
|
|
539
|
+
<p>${this.escapeHtml(message)}</p>
|
|
540
|
+
${stack}
|
|
541
|
+
</body>
|
|
542
|
+
</html>`;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Clear page cache
|
|
547
|
+
*/
|
|
548
|
+
clearCache(): void {
|
|
549
|
+
this.pageCache.clear();
|
|
550
|
+
this.logger.debug("Page cache cleared");
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Get configuration
|
|
555
|
+
*/
|
|
556
|
+
getConfig(): SSRConfig {
|
|
557
|
+
return { ...this.config };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Check if streaming is enabled
|
|
562
|
+
*/
|
|
563
|
+
isStreamingEnabled(): boolean {
|
|
564
|
+
return this.config.streaming;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Get framework
|
|
569
|
+
*/
|
|
570
|
+
getFramework(): FrontendFramework {
|
|
571
|
+
return this.config.framework;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ============= Factory Function =============
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Create an SSR renderer
|
|
579
|
+
*/
|
|
580
|
+
export function createSSRRenderer(config: PartialSSRConfig): SSRRenderer {
|
|
581
|
+
return new SSRRenderer(config);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ============= Utility Functions =============
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Create SSR context from request
|
|
588
|
+
*/
|
|
589
|
+
export function createSSRContext(
|
|
590
|
+
request: Request,
|
|
591
|
+
params: Record<string, string> = {}
|
|
592
|
+
): SSRContext {
|
|
593
|
+
const url = new URL(request.url);
|
|
594
|
+
|
|
595
|
+
return {
|
|
596
|
+
url: request.url,
|
|
597
|
+
request,
|
|
598
|
+
headers: new Headers(),
|
|
599
|
+
status: 200,
|
|
600
|
+
head: [],
|
|
601
|
+
body: [],
|
|
602
|
+
data: {},
|
|
603
|
+
modules: new Set(),
|
|
604
|
+
pathname: url.pathname,
|
|
605
|
+
query: url.searchParams,
|
|
606
|
+
params,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Serialize data for hydration
|
|
612
|
+
*/
|
|
613
|
+
export function serializeHydrationData(data: SSRHydrationData): string {
|
|
614
|
+
return JSON.stringify(data);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Deserialize hydration data
|
|
619
|
+
*/
|
|
620
|
+
export function deserializeHydrationData(json: string): SSRHydrationData {
|
|
621
|
+
return JSON.parse(json);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Generate HTML template
|
|
626
|
+
*/
|
|
627
|
+
export function generateHTML(options: {
|
|
628
|
+
head: string;
|
|
629
|
+
body: string;
|
|
630
|
+
data: Record<string, unknown>;
|
|
631
|
+
scripts: string[];
|
|
632
|
+
styles: string[];
|
|
633
|
+
}): string {
|
|
634
|
+
const { head, body, data, scripts, styles } = options;
|
|
635
|
+
|
|
636
|
+
return `<!DOCTYPE html>
|
|
637
|
+
<html>
|
|
638
|
+
<head>
|
|
639
|
+
<meta charset="utf-8">
|
|
640
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
641
|
+
${styles.join("\n")}
|
|
642
|
+
${head}
|
|
643
|
+
</head>
|
|
644
|
+
<body>
|
|
645
|
+
<div id="app">${body}</div>
|
|
646
|
+
<script type="application/json" id="__DATA__">${JSON.stringify(data)}</script>
|
|
647
|
+
${scripts.join("\n")}
|
|
648
|
+
</body>
|
|
649
|
+
</html>`;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Create preload link tag
|
|
654
|
+
*/
|
|
655
|
+
export function createPreloadLink(link: PreloadLink): string {
|
|
656
|
+
const attrs = Object.entries(link.attrs || {})
|
|
657
|
+
.map(([key, value]) => `${key}="${value}"`)
|
|
658
|
+
.join(" ");
|
|
659
|
+
|
|
660
|
+
if (link.as) {
|
|
661
|
+
return `<link rel="${link.rel}" href="${link.href}" as="${link.as}" ${attrs}>`;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return `<link rel="${link.rel}" href="${link.href}" ${attrs}>`;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Merge multiple head element arrays
|
|
669
|
+
*/
|
|
670
|
+
export function mergeHeadElements(...arrays: SSRElement[][]): SSRElement[] {
|
|
671
|
+
const seen = new Set<string>();
|
|
672
|
+
const result: SSRElement[] = [];
|
|
673
|
+
|
|
674
|
+
for (const arr of arrays) {
|
|
675
|
+
for (const element of arr) {
|
|
676
|
+
// Create a key based on tag and identifying attributes
|
|
677
|
+
const key = element.tag === "title"
|
|
678
|
+
? "title"
|
|
679
|
+
: element.tag === "meta" && element.attrs.name
|
|
680
|
+
? `meta:${element.attrs.name}`
|
|
681
|
+
: element.tag === "meta" && element.attrs.property
|
|
682
|
+
? `meta:${element.attrs.property}`
|
|
683
|
+
: JSON.stringify(element);
|
|
684
|
+
|
|
685
|
+
if (!seen.has(key)) {
|
|
686
|
+
seen.add(key);
|
|
687
|
+
result.push(element);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return result;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Re-export framework renderers
|
|
696
|
+
export { createReactSSRRenderer, type ReactSSRRenderer } from "./ssr/react.js";
|
|
697
|
+
export { createVueSSRRenderer, type VueSSRRenderer } from "./ssr/vue.js";
|
|
698
|
+
export { createSvelteSSRRenderer, type SvelteSSRRenderer } from "./ssr/svelte.js";
|
|
699
|
+
export { createSolidSSRRenderer, type SolidSSRRenderer } from "./ssr/solid.js";
|