meno-core 1.0.26 → 1.0.28

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.
Files changed (42) hide show
  1. package/bin/cli.ts +10 -14
  2. package/build-static.ts +88 -98
  3. package/entries/server-router.tsx +1 -1
  4. package/lib/client/routing/Router.tsx +18 -3
  5. package/lib/server/__integration__/test-helpers.ts +1 -1
  6. package/lib/server/createServer.ts +67 -74
  7. package/lib/server/fileWatcher.ts +29 -2
  8. package/lib/server/index.ts +6 -0
  9. package/lib/server/jsonLoader.ts +48 -52
  10. package/lib/server/projectContext.ts +4 -1
  11. package/lib/server/providers/fileSystemCMSProvider.ts +22 -13
  12. package/lib/server/providers/fileSystemPageProvider.ts +13 -9
  13. package/lib/server/routes/api/functions.ts +5 -7
  14. package/lib/server/routes/index.ts +3 -5
  15. package/lib/server/routes/pages.ts +8 -11
  16. package/lib/server/routes/static.ts +20 -49
  17. package/lib/server/runtime/bundler.ts +285 -0
  18. package/lib/server/runtime/fs.ts +232 -0
  19. package/lib/server/runtime/httpServer.ts +228 -0
  20. package/lib/server/runtime/index.ts +36 -0
  21. package/lib/server/scriptCache.ts +3 -1
  22. package/lib/server/services/ColorService.ts +2 -1
  23. package/lib/server/services/EnumService.ts +18 -13
  24. package/lib/server/services/VariableService.ts +2 -1
  25. package/lib/server/services/componentService.ts +7 -9
  26. package/lib/server/services/configService.ts +3 -3
  27. package/lib/server/services/fileWatcherService.ts +3 -1
  28. package/lib/server/ssr/htmlGenerator.test.ts +2 -1
  29. package/lib/server/ssr/htmlGenerator.ts +2 -2
  30. package/lib/server/ssr/imageMetadata.ts +3 -3
  31. package/lib/server/ssr/jsCollector.ts +3 -39
  32. package/lib/server/ssr/ssrRenderer.ts +20 -3
  33. package/lib/server/websocketManager.ts +7 -13
  34. package/lib/shared/cssGeneration.test.ts +10 -0
  35. package/lib/shared/cssGeneration.ts +1 -1
  36. package/lib/shared/fontLoader.ts +3 -3
  37. package/lib/shared/libraryLoader.test.ts +91 -0
  38. package/lib/shared/libraryLoader.ts +43 -0
  39. package/lib/shared/utilityClassMapper.test.ts +5 -0
  40. package/lib/shared/utilityClassMapper.ts +6 -0
  41. package/package.json +20 -3
  42. package/scripts/build-for-publish.mjs +43 -0
package/bin/cli.ts CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env node
2
2
  /**
3
3
  * meno CLI
4
4
  * Commands: dev, build, init
@@ -8,6 +8,7 @@ import { resolve, join } from 'path';
8
8
  import { existsSync, mkdirSync, writeFileSync, cpSync, readFileSync } from 'fs';
9
9
  import { setProjectRoot } from '../lib/server/projectContext';
10
10
  import { generateBuildErrorPage, type BuildErrorsData } from '../lib/server/ssr/buildErrorOverlay';
11
+ import { createRuntimeServer, serveFile, fileExists as runtimeFileExists } from '../lib/server/runtime';
11
12
 
12
13
  const args = process.argv.slice(2);
13
14
  const command = args[0];
@@ -17,7 +18,7 @@ function printHelp() {
17
18
  meno - Visual editor for JSON-based pages
18
19
 
19
20
  Requirements:
20
- - Bun (https://bun.sh)
21
+ - Bun or Node.js 18+
21
22
 
22
23
  Usage:
23
24
  meno <command> [options]
@@ -100,7 +101,7 @@ async function startStaticServer(distPath: string) {
100
101
  // Parse _headers file once on startup
101
102
  const headersMap = parseHeadersFile(distPath);
102
103
 
103
- const server = Bun.serve({
104
+ const server = await createRuntimeServer({
104
105
  port: SERVE_PORT,
105
106
  hostname: 'localhost',
106
107
  async fetch(req: Request) {
@@ -134,25 +135,20 @@ async function startStaticServer(distPath: string) {
134
135
  const filePath = join(distPath, pathname);
135
136
 
136
137
  // Check if file exists and serve it
137
- if (existsSync(filePath)) {
138
- const file = Bun.file(filePath);
139
- if (await file.exists()) {
140
- return new Response(file, { headers: customHeaders });
141
- }
138
+ if (await runtimeFileExists(filePath)) {
139
+ return await serveFile(filePath, customHeaders);
142
140
  }
143
141
 
144
142
  // Try with .html extension for clean URLs
145
143
  const htmlPath = filePath.endsWith('.html') ? filePath : `${filePath}.html`;
146
- if (existsSync(htmlPath)) {
147
- const file = Bun.file(htmlPath);
148
- return new Response(file, { headers: customHeaders });
144
+ if (await runtimeFileExists(htmlPath)) {
145
+ return await serveFile(htmlPath, customHeaders);
149
146
  }
150
147
 
151
148
  // Try index.html in directory
152
149
  const indexPath = join(filePath, 'index.html');
153
- if (existsSync(indexPath)) {
154
- const file = Bun.file(indexPath);
155
- return new Response(file, { headers: customHeaders });
150
+ if (await runtimeFileExists(indexPath)) {
151
+ return await serveFile(indexPath, customHeaders);
156
152
  }
157
153
 
158
154
  return new Response('Not Found', { status: 404 });
package/build-static.ts CHANGED
@@ -9,6 +9,7 @@ import { writeFile, readFile } from "fs/promises";
9
9
  import { join } from "path";
10
10
  import type { BuildError, BuildErrorsData } from "./lib/server/ssr/buildErrorOverlay";
11
11
  import { createHash } from "crypto";
12
+ import { inspect, minifyJS as runtimeMinifyJS } from './lib/server/runtime';
12
13
  import {
13
14
  loadJSONFile,
14
15
  loadComponentDirectory,
@@ -31,6 +32,8 @@ import { buildItemUrl } from "./lib/shared/itemTemplateUtils";
31
32
  import { generateMiddleware, generateTrackFunction, generateResultsFunction } from "./lib/server/ab/generateFunctions";
32
33
  import { generateTrackingScript } from "./lib/server/ab/trackingScript";
33
34
  import { migrateTemplatesDirectory } from "./lib/server/migrateTemplates";
35
+ import { extractLibraryOrigins, collectComponentLibraries } from "./lib/shared/libraryLoader";
36
+ import type { LibrariesConfig } from "./lib/shared/types/libraries";
34
37
 
35
38
  /**
36
39
  * Collect build errors for error overlay
@@ -78,65 +81,15 @@ export function formatBunLog(log: any): string {
78
81
  }
79
82
 
80
83
  /**
81
- * Minify JavaScript code using Bun's built-in bundler
84
+ * Minify JavaScript code using runtime bundler
82
85
  * Throws on error instead of silently failing
83
86
  */
84
87
  async function minifyJS(code: string): Promise<string> {
85
- const tempFile = join('/tmp', `meno-minify-${Date.now()}.js`);
86
- try {
87
- await writeFile(tempFile, code, 'utf-8');
88
-
89
- // Use throw: true to get detailed error information
90
- const result = await Bun.build({
91
- entrypoints: [tempFile],
92
- minify: true,
93
- throw: true, // This makes Bun throw with detailed error info
94
- });
95
-
96
- if (result.outputs.length > 0) {
97
- return await result.outputs[0].text();
98
- }
99
-
100
- throw new Error('JavaScript minification produced no output');
101
- } catch (err: any) {
102
- // Re-throw with formatted message that includes all details
103
- let details = '';
104
-
105
- // Bun's BuildError has a logs array with detailed info
106
- if (err.logs && Array.isArray(err.logs)) {
107
- details = err.logs.map((log: any) => {
108
- const parts: string[] = [];
109
- if (log.position?.line) {
110
- parts.push(`Line ${log.position.line}:${log.position.column || 0}`);
111
- }
112
- if (log.position?.lineText) {
113
- parts.push(log.position.lineText);
114
- }
115
- if (log.message) {
116
- parts.push(log.message);
117
- }
118
- return parts.length > 0 ? parts.join('\n') : String(log);
119
- }).join('\n\n');
120
- }
121
-
122
- // If no logs, try Bun.inspect for full error details
123
- if (!details) {
124
- try {
125
- details = Bun.inspect(err);
126
- } catch {
127
- details = err.stack || err.message || String(err);
128
- }
129
- }
130
-
131
- throw new Error(`JavaScript minification failed:\n${details}`);
132
- } finally {
133
- // Clean up temp file
134
- try {
135
- rmSync(tempFile, { force: true });
136
- } catch {
137
- // Ignore cleanup errors
138
- }
88
+ const result = await runtimeMinifyJS(code);
89
+ if (result.success) {
90
+ return result.code;
139
91
  }
92
+ throw new Error(`JavaScript minification failed:\n${result.errors.join('\n\n')}`);
140
93
  }
141
94
 
142
95
  /**
@@ -554,11 +507,10 @@ async function buildCMSTemplates(
554
507
  errorMessage = String(error);
555
508
  }
556
509
 
557
- // If we still just have "Bundle failed", try to get more from Bun.inspect
510
+ // If we still just have "Bundle failed", try to get more from inspect
558
511
  if (errorMessage === 'Bundle failed' || errorMessage.includes('Bundle failed\n')) {
559
512
  try {
560
- // Bun.inspect gives the same output as console.log
561
- const inspected = Bun.inspect(error);
513
+ const inspected = inspect(error);
562
514
  if (inspected && inspected !== '[object Object]' && inspected.length > errorMessage.length) {
563
515
  errorMessage = inspected;
564
516
  }
@@ -723,34 +675,9 @@ export async function buildStaticPages(): Promise<void> {
723
675
  }
724
676
  }
725
677
 
726
- // Generate _headers from CSP config if no custom _headers file exists
727
- if (!hostingFiles.includes('_headers')) {
728
- await configService.load();
729
- const cspConfig = configService.getCSP();
730
- if (cspConfig && Object.keys(cspConfig).length > 0) {
731
- const extraScripts = cspConfig.scriptSrc?.join(' ') || '';
732
- const extraStyles = cspConfig.styleSrc?.join(' ') || '';
733
- const extraConnect = cspConfig.connectSrc?.join(' ') || '';
734
- const extraFrames = cspConfig.frameSrc?.join(' ') || '';
735
- const extraFonts = cspConfig.fontSrc?.join(' ') || '';
736
- const extraImgs = cspConfig.imgSrc?.join(' ') || '';
737
-
738
- const cspDirectives = [
739
- "default-src 'self'",
740
- `script-src 'self' 'unsafe-inline' https://f.vimeocdn.com https://player.vimeo.com https://www.youtube.com https://s.ytimg.com ${extraScripts}`.trim(),
741
- `style-src 'self' 'unsafe-inline' https://f.vimeocdn.com ${extraStyles}`.trim(),
742
- `img-src 'self' data: https: ${extraImgs}`.trim(),
743
- `connect-src 'self' https://vimeo.com https://*.vimeocdn.com ${extraConnect}`.trim(),
744
- `frame-src https://player.vimeo.com https://vimeo.com https://www.youtube.com https://www.youtube-nocookie.com ${extraFrames}`.trim(),
745
- `font-src 'self' data: ${extraFonts}`.trim(),
746
- "media-src 'self' https: blob:"
747
- ].join('; ');
748
-
749
- const headersContent = `/*\n Content-Security-Policy: ${cspDirectives}\n`;
750
- writeFileSync(join(distDir, '_headers'), headersContent);
751
- hostingFiles.push('_headers (generated from csp config)');
752
- }
753
- }
678
+ // _headers generation is deferred until after components and pages are loaded
679
+ // so that library CDN domains can be auto-added to CSP
680
+ const hasCustomHeaders = hostingFiles.includes('_headers');
754
681
 
755
682
  // Copy .well-known directory if exists
756
683
  const wellKnownDir = join(projectPaths.project, '.well-known');
@@ -786,7 +713,19 @@ export async function buildStaticPages(): Promise<void> {
786
713
  process.exit(1);
787
714
  }
788
715
 
789
- const pageFiles = readdirSync(pagesDir).filter((f) => f.endsWith(".json"));
716
+ // Recursively collect all .json page files (supports nested folders like pages/a/b.json)
717
+ const pageFiles: string[] = [];
718
+ function scanPagesDir(dir: string, prefix: string): void {
719
+ const entries = readdirSync(dir, { withFileTypes: true });
720
+ for (const entry of entries) {
721
+ if (entry.isFile() && entry.name.endsWith('.json')) {
722
+ pageFiles.push(prefix ? `${prefix}/${entry.name}` : entry.name);
723
+ } else if (entry.isDirectory()) {
724
+ scanPagesDir(join(dir, entry.name), prefix ? `${prefix}/${entry.name}` : entry.name);
725
+ }
726
+ }
727
+ }
728
+ scanPagesDir(pagesDir, '');
790
729
 
791
730
  if (pageFiles.length === 0) {
792
731
  console.warn("⚠️ No pages found in ./pages directory");
@@ -795,8 +734,9 @@ export async function buildStaticPages(): Promise<void> {
795
734
 
796
735
  console.log(`📄 Found ${pageFiles.length} page(s) to build\n`);
797
736
 
798
- // First pass: collect all slug mappings for LocaleList SSR
737
+ // First pass: collect all slug mappings and page-level libraries
799
738
  const slugMappings: SlugMap[] = [];
739
+ const allPageLibraries: LibrariesConfig[] = [];
800
740
  for (const file of pageFiles) {
801
741
  const pageName = file.replace(".json", "");
802
742
  const basePath = mapPageNameToPath(pageName);
@@ -808,10 +748,68 @@ export async function buildStaticPages(): Promise<void> {
808
748
  const pageId = basePath === '/' ? 'index' : basePath.substring(1);
809
749
  slugMappings.push({ pageId, slugs: pageData.meta.slugs });
810
750
  }
751
+ if (pageData.meta?.libraries) {
752
+ allPageLibraries.push(pageData.meta.libraries as LibrariesConfig);
753
+ }
811
754
  } catch { /* ignore parse errors in first pass */ }
812
755
  }
813
756
  }
814
757
 
758
+ // Generate _headers from CSP config, auto-including library CDN domains
759
+ if (!hasCustomHeaders) {
760
+ await configService.load();
761
+ const cspConfig = configService.getCSP();
762
+ if (cspConfig && Object.keys(cspConfig).length > 0) {
763
+ // Collect all library sources: global + component + page-level
764
+ // Note: we concatenate (not mergeLibraries) because _headers applies globally —
765
+ // we need the union of ALL origins, regardless of per-page merge modes
766
+ const globalLibraries = configService.getLibraries();
767
+ const componentLibraries = collectComponentLibraries(globalComponents);
768
+ const allJs = [
769
+ ...(globalLibraries.js || []),
770
+ ...(componentLibraries.js || []),
771
+ ...allPageLibraries.flatMap(p => p.js || []),
772
+ ];
773
+ const allCss = [
774
+ ...(globalLibraries.css || []),
775
+ ...(componentLibraries.css || []),
776
+ ...allPageLibraries.flatMap(p => p.css || []),
777
+ ];
778
+ const allLibs: LibrariesConfig = { js: allJs, css: allCss };
779
+
780
+ // Extract CDN origins from all library URLs
781
+ const { scriptOrigins, styleOrigins } = extractLibraryOrigins(allLibs);
782
+
783
+ const extraScripts = [
784
+ ...(cspConfig.scriptSrc || []),
785
+ ...Array.from(scriptOrigins),
786
+ ].join(' ');
787
+ const extraStyles = [
788
+ ...(cspConfig.styleSrc || []),
789
+ ...Array.from(styleOrigins),
790
+ ].join(' ');
791
+ const extraConnect = cspConfig.connectSrc?.join(' ') || '';
792
+ const extraFrames = cspConfig.frameSrc?.join(' ') || '';
793
+ const extraFonts = cspConfig.fontSrc?.join(' ') || '';
794
+ const extraImgs = cspConfig.imgSrc?.join(' ') || '';
795
+
796
+ const cspDirectives = [
797
+ "default-src 'self'",
798
+ `script-src 'self' 'unsafe-inline' https://f.vimeocdn.com https://player.vimeo.com https://www.youtube.com https://s.ytimg.com ${extraScripts}`.trim(),
799
+ `style-src 'self' 'unsafe-inline' https://f.vimeocdn.com ${extraStyles}`.trim(),
800
+ `img-src 'self' data: https: ${extraImgs}`.trim(),
801
+ `connect-src 'self' https://vimeo.com https://*.vimeocdn.com ${extraConnect}`.trim(),
802
+ `frame-src https://player.vimeo.com https://vimeo.com https://www.youtube.com https://www.youtube-nocookie.com ${extraFrames}`.trim(),
803
+ `font-src 'self' data: ${extraFonts}`.trim(),
804
+ "media-src 'self' https: blob:"
805
+ ].join('; ');
806
+
807
+ const headersContent = `/*\n Content-Security-Policy: ${cspDirectives}\n`;
808
+ writeFileSync(join(distDir, '_headers'), headersContent);
809
+ console.log(`✅ Generated _headers with CSP (auto-included ${scriptOrigins.size + styleOrigins.size} library origin(s))\n`);
810
+ }
811
+ }
812
+
815
813
  let successCount = 0;
816
814
  let errorCount = 0;
817
815
 
@@ -930,11 +928,10 @@ export async function buildStaticPages(): Promise<void> {
930
928
  errorMessage = String(error);
931
929
  }
932
930
 
933
- // If we still just have "Bundle failed", try to get more from Bun.inspect
931
+ // If we still just have "Bundle failed", try to get more from inspect
934
932
  if (errorMessage === 'Bundle failed' || errorMessage.includes('Bundle failed\n')) {
935
933
  try {
936
- // Bun.inspect gives the same output as console.log
937
- const inspected = Bun.inspect(error);
934
+ const inspected = inspect(error);
938
935
  if (inspected && inspected !== '[object Object]' && inspected.length > errorMessage.length) {
939
936
  errorMessage = inspected;
940
937
  }
@@ -1044,11 +1041,4 @@ export async function buildStaticPages(): Promise<void> {
1044
1041
  }
1045
1042
  }
1046
1043
 
1047
- // Run build only when executed directly (CLI), not when imported as a module
1048
- if (import.meta.main) {
1049
- await buildStaticPages().catch((error) => {
1050
- console.error("❌ Build failed:", error);
1051
- process.exit(1);
1052
- });
1053
- }
1054
1044
 
@@ -64,7 +64,7 @@ async function initialize() {
64
64
  await initialize();
65
65
 
66
66
  // Create and start server using the factory
67
- const { server, port } = createServer({
67
+ const { server, port } = await createServer({
68
68
  pageService,
69
69
  componentService,
70
70
  wsManager,
@@ -142,6 +142,10 @@ export function Router(props: RouterProps = {}): ReactElement {
142
142
  // Store CMS template path for HMR reloads (so we don't lose it after initial load)
143
143
  const [cmsTemplatePath, setCmsTemplatePath] = useState<string | null>(null);
144
144
 
145
+ // Grace period: skip HMR reloads shortly after receiving committed data via postMessage
146
+ const lastCommitTimestampRef = useRef(0);
147
+ const COMMIT_GRACE_MS = 1000;
148
+
145
149
  // Track if initial mount used SSR CMS context (to skip redundant path-based load)
146
150
  const ssrCmsHandledRef = useRef(false);
147
151
  // Track if initial load is done (to prevent currentPath effect from firing on mount)
@@ -292,6 +296,7 @@ export function Router(props: RouterProps = {}): ReactElement {
292
296
  setPreviewComponentTree(null);
293
297
  } else if (event.data?.type === IFRAME_MESSAGE_TYPES.PAGE_DATA_COMMITTED) {
294
298
  // Update the real tree with committed editor mutations (instant preview)
299
+ lastCommitTimestampRef.current = Date.now();
295
300
  const pageData = event.data.pageData;
296
301
  if (pageData?.root) {
297
302
  setComponentTree(pageData.root);
@@ -299,6 +304,7 @@ export function Router(props: RouterProps = {}): ReactElement {
299
304
  componentTreeRef.current = pageData.root;
300
305
  }
301
306
  } else if (event.data?.type === IFRAME_MESSAGE_TYPES.COMPONENT_DEFINITION_COMMITTED) {
307
+ lastCommitTimestampRef.current = Date.now();
302
308
  const { componentName, definition } = event.data;
303
309
  if (componentName && definition) {
304
310
  services.componentRegistry.register(componentName, definition);
@@ -332,7 +338,7 @@ export function Router(props: RouterProps = {}): ReactElement {
332
338
  // Clear registry when tree is cleared (no elements to register)
333
339
  elementRegistry.clear();
334
340
  }
335
- }, [previewComponentTree, componentTree, services, cmsContext]);
341
+ }, [previewComponentTree, componentTree, services, cmsContext, registryVersion]);
336
342
 
337
343
  // Post-paint: send interactive styles to parent and execute scripts
338
344
  useEffect(() => {
@@ -361,7 +367,7 @@ export function Router(props: RouterProps = {}): ReactElement {
361
367
  };
362
368
  }
363
369
  }
364
- }, [previewComponentTree, componentTree, services, disableScripts, cmsContext]);
370
+ }, [previewComponentTree, componentTree, services, disableScripts, cmsContext, registryVersion]);
365
371
 
366
372
  // Prefetch all internal links when using 'load' strategy
367
373
  useEffect(() => {
@@ -391,6 +397,15 @@ export function Router(props: RouterProps = {}): ReactElement {
391
397
  await routeLoader.loadComponents(pathToLoad);
392
398
  }, [cmsTemplatePath]);
393
399
 
400
+ // Grace-aware version for HMR: skip reload if iframe just received committed data via postMessage
401
+ const loadComponentsForHMR = useCallback(async (path: string) => {
402
+ if (Date.now() - lastCommitTimestampRef.current < COMMIT_GRACE_MS) {
403
+ return;
404
+ }
405
+ const pathToLoad = cmsTemplatePath || path;
406
+ await routeLoader.loadComponents(pathToLoad);
407
+ }, [cmsTemplatePath]);
408
+
394
409
  // Ref for loadComponents to avoid recreating effects when cmsTemplatePath changes
395
410
  const loadComponentsRef = useRef(loadComponents);
396
411
  useEffect(() => { loadComponentsRef.current = loadComponents; }, [loadComponents]);
@@ -626,7 +641,7 @@ export function Router(props: RouterProps = {}): ReactElement {
626
641
  // Render HMRManager once at the top level with page content
627
642
  return h('div', null,
628
643
  h(HMRManager, {
629
- onReload: loadComponents,
644
+ onReload: loadComponentsForHMR,
630
645
  onCMSUpdate: handleCMSUpdate,
631
646
  currentPath: currentPath,
632
647
  }),
@@ -162,7 +162,7 @@ export async function createTestServer(options: {
162
162
  cmsProvider: options.cmsProvider,
163
163
  };
164
164
 
165
- const { server, port: actualPort } = createServer(config);
165
+ const { server, port: actualPort } = await createServer(config);
166
166
  const baseUrl = `http://localhost:${actualPort}`;
167
167
 
168
168
  return {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Server Factory
3
- * Creates a configurable Bun server for meno-core
3
+ * Creates a runtime-agnostic server for meno-core
4
4
  * Can be extended by @meno/studio for editor functionality
5
5
  */
6
6
 
@@ -11,16 +11,10 @@ import type { CMSProvider } from '../shared/interfaces/contentProvider';
11
11
  import { WebSocketManager } from './websocketManager';
12
12
  import { handleRoutes, type RouteContext } from './routes';
13
13
  import { SERVER_PORT, MAX_PORT_ATTEMPTS, HMR_ROUTE } from '../shared/constants';
14
-
15
- // Bun WebSocket type
16
- type BunWebSocket = {
17
- send(data: string | ArrayBuffer | Uint8Array): void;
18
- readyState: number;
19
- close(code?: number, reason?: string): void;
20
- };
14
+ import { createRuntimeServer, type RuntimeServer, type RuntimeWSClient } from './runtime';
21
15
 
22
16
  // Custom message handler type
23
- export type WSMessageHandler = (ws: BunWebSocket, data: unknown) => void;
17
+ export type WSMessageHandler = (ws: RuntimeWSClient, data: unknown) => void;
24
18
 
25
19
  /**
26
20
  * Server configuration
@@ -54,14 +48,14 @@ export type AdditionalRouteHandler = (
54
48
  * Server factory result
55
49
  */
56
50
  export interface ServerResult {
57
- server: ReturnType<typeof Bun.serve>;
51
+ server: RuntimeServer;
58
52
  port: number;
59
53
  }
60
54
 
61
55
  /**
62
- * Create a Bun server with the given configuration
56
+ * Create a server with the given configuration
63
57
  */
64
- export function createServer(config: ServerConfig): ServerResult {
58
+ export async function createServer(config: ServerConfig): Promise<ServerResult> {
65
59
  const {
66
60
  port: requestedPort = SERVER_PORT,
67
61
  pageService,
@@ -82,74 +76,77 @@ export function createServer(config: ServerConfig): ServerResult {
82
76
  injectLiveReload,
83
77
  };
84
78
 
85
- function createBunConfig(port: number) {
86
- return {
87
- port,
88
- hostname: 'localhost',
89
- development: {
90
- hmr: true,
91
- },
92
-
93
- async fetch(req: Request, server: any) {
94
- const url = new URL(req.url);
95
-
96
- // Check additional routes first (allows studio to inject routes)
97
- for (const handler of additionalRoutes) {
98
- const response = await handler(req, url, routeContext);
99
- if (response) {
100
- return response;
101
- }
102
- }
79
+ // Try to start server, incrementing port if busy
80
+ let lastError: unknown;
103
81
 
104
- // Handle all routes through centralized router
105
- const response = await handleRoutes(req, url, server, routeContext);
106
- return response || new Response('Not Found', { status: 404 });
107
- },
82
+ for (let portAttempt = 0; portAttempt < MAX_PORT_ATTEMPTS; portAttempt++) {
83
+ const currentPort = requestedPort + portAttempt;
108
84
 
109
- websocket: {
110
- open(ws: BunWebSocket) {
111
- wsManager.addClient(ws);
112
- },
113
- message(ws: BunWebSocket, message: unknown) {
114
- try {
115
- const data = JSON.parse(message as string);
116
-
117
- // Respond to ping with pong (for heartbeat)
118
- if (data.type === 'ping') {
119
- ws.send('pong');
120
- return;
85
+ try {
86
+ const server = await createRuntimeServer({
87
+ port: currentPort,
88
+ hostname: 'localhost',
89
+ wsPath: HMR_ROUTE,
90
+
91
+ async fetch(req: Request, upgradeWebSocket: (req: Request) => boolean) {
92
+ const url = new URL(req.url);
93
+
94
+ // Check additional routes first (allows studio to inject routes)
95
+ for (const handler of additionalRoutes) {
96
+ const response = await handler(req, url, routeContext);
97
+ if (response) {
98
+ return response;
121
99
  }
100
+ }
122
101
 
123
- // Allow custom message handlers
124
- if (onWSMessage) {
125
- onWSMessage(ws, data);
126
- }
127
- } catch (error) {
128
- // Ignore non-JSON messages
102
+ // WebSocket upgrade for HMR
103
+ if (url.pathname === HMR_ROUTE) {
104
+ const success = upgradeWebSocket(req);
105
+ return success
106
+ ? undefined
107
+ : new Response('WebSocket upgrade failed', { status: 500 });
129
108
  }
130
- },
131
- close(ws: BunWebSocket) {
132
- wsManager.removeClient(ws);
133
- },
134
- },
135
- };
136
- }
137
109
 
138
- // Try to start server, incrementing port if busy
139
- let server: ReturnType<typeof Bun.serve> | undefined;
140
- let actualPort = requestedPort;
110
+ // Handle all routes through centralized router
111
+ const response = await handleRoutes(req, url, null, routeContext);
112
+ return response || new Response('Not Found', { status: 404 });
113
+ },
141
114
 
142
- for (let portAttempt = 0; portAttempt < MAX_PORT_ATTEMPTS; portAttempt++) {
143
- const currentPort = requestedPort + portAttempt;
115
+ websocket: {
116
+ open(ws: RuntimeWSClient) {
117
+ wsManager.addClient(ws);
118
+ },
119
+ message(ws: RuntimeWSClient, message: string) {
120
+ try {
121
+ const data = JSON.parse(message);
122
+
123
+ // Respond to ping with pong (for heartbeat)
124
+ if (data.type === 'ping') {
125
+ ws.send('pong');
126
+ return;
127
+ }
128
+
129
+ // Allow custom message handlers
130
+ if (onWSMessage) {
131
+ onWSMessage(ws, data);
132
+ }
133
+ } catch (error) {
134
+ // Ignore non-JSON messages
135
+ }
136
+ },
137
+ close(ws: RuntimeWSClient) {
138
+ wsManager.removeClient(ws);
139
+ },
140
+ },
141
+ });
144
142
 
145
- try {
146
- server = Bun.serve(createBunConfig(currentPort));
147
- actualPort = server.port ?? currentPort;
148
143
  if (portAttempt > 0) {
149
- console.log(`Warning: Port ${requestedPort} was busy, using port ${actualPort} instead`);
144
+ console.log(`Warning: Port ${requestedPort} was busy, using port ${server.port} instead`);
150
145
  }
151
- break;
146
+
147
+ return { server, port: server.port };
152
148
  } catch (error: unknown) {
149
+ lastError = error;
153
150
  const err = error as { code?: string };
154
151
  if (err?.code === 'EADDRINUSE') {
155
152
  if (portAttempt >= MAX_PORT_ATTEMPTS - 1) {
@@ -164,11 +161,7 @@ export function createServer(config: ServerConfig): ServerResult {
164
161
  }
165
162
  }
166
163
 
167
- if (!server) {
168
- throw new Error('Failed to start server');
169
- }
170
-
171
- return { server, port: actualPort };
164
+ throw lastError || new Error('Failed to start server');
172
165
  }
173
166
 
174
167
  /**
@@ -16,6 +16,7 @@ export interface FileWatchCallbacks {
16
16
  onVariablesChange?: () => Promise<void>;
17
17
  onEnumsChange?: () => Promise<void>;
18
18
  onCMSChange?: (collection: string) => Promise<void>;
19
+ onImageAdded?: (filename: string) => Promise<void>;
19
20
  }
20
21
 
21
22
  export class FileWatcher {
@@ -26,7 +27,8 @@ export class FileWatcher {
26
27
  private variablesWatcher: FSWatcher | null = null;
27
28
  private enumsWatcher: FSWatcher | null = null;
28
29
  private cmsWatcher: FSWatcher | null = null;
29
-
30
+ private imagesWatcher: FSWatcher | null = null;
31
+
30
32
  constructor(private callbacks: FileWatchCallbacks) {}
31
33
 
32
34
  /**
@@ -183,6 +185,25 @@ export class FileWatcher {
183
185
  );
184
186
  }
185
187
 
188
+ /**
189
+ * Start watching images directory for new source images
190
+ */
191
+ watchImages(dirPath: string = projectPaths.images()): void {
192
+ if (!existsSync(dirPath)) {
193
+ return;
194
+ }
195
+
196
+ this.imagesWatcher = watch(
197
+ dirPath,
198
+ { recursive: false },
199
+ async (event, filename) => {
200
+ if (event === 'rename' && filename && this.callbacks.onImageAdded) {
201
+ await this.callbacks.onImageAdded(filename);
202
+ }
203
+ }
204
+ );
205
+ }
206
+
186
207
  /**
187
208
  * Start watching all directories
188
209
  */
@@ -194,6 +215,7 @@ export class FileWatcher {
194
215
  this.watchVariables();
195
216
  this.watchEnums();
196
217
  this.watchCMS();
218
+ this.watchImages();
197
219
  }
198
220
 
199
221
  /**
@@ -234,13 +256,18 @@ export class FileWatcher {
234
256
  this.cmsWatcher.close();
235
257
  this.cmsWatcher = null;
236
258
  }
259
+
260
+ if (this.imagesWatcher) {
261
+ this.imagesWatcher.close();
262
+ this.imagesWatcher = null;
263
+ }
237
264
  }
238
265
 
239
266
  /**
240
267
  * Check if watchers are active
241
268
  */
242
269
  isWatching(): boolean {
243
- return this.componentsWatcher !== null || this.pagesWatcher !== null || this.cmsWatcher !== null;
270
+ return this.componentsWatcher !== null || this.pagesWatcher !== null || this.cmsWatcher !== null || this.imagesWatcher !== null;
244
271
  }
245
272
  }
246
273