@kuratchi/js 0.0.3 → 0.0.4

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Compiler — scans a project's routes/ directory, parses .html files,
2
+ * Compiler â€" scans a project's routes/ directory, parses .html files,
3
3
  * and generates a single Worker entry point.
4
4
  */
5
5
  import { parseFile } from './parser.js';
@@ -31,10 +31,36 @@ function compactInlineJs(source) {
31
31
  .replace(/\s*([{}();,:])\s*/g, '$1')
32
32
  .trim();
33
33
  }
34
+ function parseNamedImportBindings(line) {
35
+ const namesMatch = line.match(/import\s*\{([^}]+)\}/);
36
+ if (!namesMatch)
37
+ return [];
38
+ return namesMatch[1]
39
+ .split(',')
40
+ .map(n => n.trim())
41
+ .filter(Boolean)
42
+ .map(n => {
43
+ const parts = n.split(/\s+as\s+/).map(p => p.trim()).filter(Boolean);
44
+ return parts[1] || parts[0] || '';
45
+ })
46
+ .filter(Boolean);
47
+ }
48
+ function filterClientImportsForServer(imports, neededFns) {
49
+ const selected = [];
50
+ for (const line of imports) {
51
+ const bindings = parseNamedImportBindings(line);
52
+ if (bindings.length === 0)
53
+ continue;
54
+ if (bindings.some(name => neededFns.has(name))) {
55
+ selected.push(line);
56
+ }
57
+ }
58
+ return selected;
59
+ }
34
60
  /**
35
61
  * Compile a project's src/routes/ into .kuratchi/routes.js
36
62
  *
37
- * The generated module exports { app } — an object with a fetch() method
63
+ * The generated module exports { app } â€" an object with a fetch() method
38
64
  * that handles routing, load functions, form actions, and rendering.
39
65
  * Returns the path to .kuratchi/worker.js — the stable wrangler entry point that
40
66
  * re-exports everything from routes.js (default fetch handler + named DO class exports).
@@ -49,21 +75,21 @@ export function compile(options) {
49
75
  }
50
76
  // Discover all .html route files
51
77
  const routeFiles = discoverRoutes(routesDir);
52
- // Component compilation cache — only compile components that are actually imported
78
+ // Component compilation cache â€" only compile components that are actually imported
53
79
  const libDir = path.join(srcDir, 'lib');
54
- const compiledComponentCache = new Map(); // fileName → compiled function code
55
- const componentStyleCache = new Map(); // fileName → escaped CSS string (or empty)
80
+ const compiledComponentCache = new Map(); // fileName â†' compiled function code
81
+ const componentStyleCache = new Map(); // fileName â†' escaped CSS string (or empty)
56
82
  // Tracks which prop names inside a component are used as action={propName}.
57
- // e.g. db-studio uses action={runQueryAction} → stores 'runQueryAction'.
83
+ // e.g. db-studio uses action={runQueryAction} â†' stores 'runQueryAction'.
58
84
  // When the route passes runQueryAction={runAdminSqlQuery}, the compiler knows
59
85
  // to add 'runAdminSqlQuery' to the route's actionFunctions.
60
- const componentActionCache = new Map(); // fileName → Set of action prop names
86
+ const componentActionCache = new Map(); // fileName â†' Set of action prop names
61
87
  function compileComponent(fileName) {
62
88
  if (compiledComponentCache.has(fileName))
63
89
  return compiledComponentCache.get(fileName);
64
90
  let filePath;
65
91
  let funcName;
66
- // Package component: "@kuratchi/ui:badge" → resolve from package
92
+ // Package component: "@kuratchi/ui:badge" â†' resolve from package
67
93
  const pkgMatch = fileName.match(/^(@[^:]+):(.+)$/);
68
94
  if (pkgMatch) {
69
95
  const pkgName = pkgMatch[1]; // e.g. "@kuratchi/ui"
@@ -96,7 +122,7 @@ export function compile(options) {
96
122
  : '';
97
123
  // template source (parseFile already removes the <script> block)
98
124
  let source = compParsed.template;
99
- // Extract optional <style> block — CSS is scoped and injected once per route at compile time
125
+ // Extract optional <style> block â€" CSS is scoped and injected once per route at compile time
100
126
  let styleBlock = '';
101
127
  const styleMatch = source.match(/<style[\s>][\s\S]*?<\/style>/i);
102
128
  if (styleMatch) {
@@ -124,8 +150,8 @@ export function compile(options) {
124
150
  : '';
125
151
  componentStyleCache.set(fileName, escapedStyle);
126
152
  // Replace <slot></slot> and <slot /> with children output
127
- source = source.replace(/<slot\s*><\/slot>/g, '{=html props.children || ""}');
128
- source = source.replace(/<slot\s*\/>/g, '{=html props.children || ""}');
153
+ source = source.replace(/<slot\s*><\/slot>/g, '{@raw props.children || ""}');
154
+ source = source.replace(/<slot\s*\/>/g, '{@raw props.children || ""}');
129
155
  // Build a sub-component map from the component's own component imports so that
130
156
  // <Alert>, <Badge>, <Dialog>, etc. get expanded instead of emitted as raw tags.
131
157
  const subComponentNames = new Map();
@@ -142,7 +168,7 @@ export function compile(options) {
142
168
  }
143
169
  }
144
170
  // Scan the component template for action={propName} uses.
145
- // These prop names are "action props" — when the route passes actionProp={routeFn},
171
+ // These prop names are "action props" â€" when the route passes actionProp={routeFn},
146
172
  // the compiler knows to add routeFn to the route's actionFunctions so it ends up
147
173
  // in the route's actions map and can be dispatched at runtime.
148
174
  const actionPropNames = new Set();
@@ -162,7 +188,7 @@ export function compile(options) {
162
188
  compiledComponentCache.set(fileName, compiled);
163
189
  return compiled;
164
190
  }
165
- // App layout: src/routes/layout.html (convention — wraps all routes automatically)
191
+ // App layout: src/routes/layout.html (convention â€" wraps all routes automatically)
166
192
  const layoutFile = path.join(routesDir, 'layout.html');
167
193
  let compiledLayout = null;
168
194
  const layoutComponentNames = new Map();
@@ -444,6 +470,17 @@ export function compile(options) {
444
470
  if(!msg) return;
445
471
  if(!window.confirm(msg)){ e.preventDefault(); e.stopPropagation(); }
446
472
  }, true);
473
+ document.addEventListener('submit', function(e){
474
+ if(e.defaultPrevented) return;
475
+ var f = e.target;
476
+ if(!f || !f.querySelector) return;
477
+ var aInput = f.querySelector('input[name="_action"]');
478
+ if(!aInput) return;
479
+ var aName = aInput.value;
480
+ if(!aName) return;
481
+ f.setAttribute('data-action-loading', aName);
482
+ Array.prototype.slice.call(f.querySelectorAll('button[type="submit"],button:not([type="button"]):not([type="reset"])')).forEach(function(b){ b.disabled = true; });
483
+ }, true);
447
484
  document.addEventListener('change', function(e){
448
485
  var t = e.target;
449
486
  if(!t || !t.getAttribute) return;
@@ -458,21 +495,118 @@ export function compile(options) {
458
495
  }, true);
459
496
  by('[data-select-all]').forEach(function(m){ var g = m.getAttribute('data-select-all'); if(g) syncGroup(g); });
460
497
  })();`;
498
+ const reactiveRuntimeSource = `(function(g){
499
+ if(g.__kuratchiReactive) return;
500
+ const targetMap = new WeakMap();
501
+ const proxyMap = new WeakMap();
502
+ let active = null;
503
+ const queue = new Set();
504
+ let flushing = false;
505
+ function queueRun(fn){
506
+ queue.add(fn);
507
+ if(flushing) return;
508
+ flushing = true;
509
+ Promise.resolve().then(function(){
510
+ try {
511
+ const jobs = Array.from(queue);
512
+ queue.clear();
513
+ for (const job of jobs) job();
514
+ } finally {
515
+ flushing = false;
516
+ }
517
+ });
518
+ }
519
+ function cleanup(effect){
520
+ const deps = effect.__deps || [];
521
+ for (const dep of deps) dep.delete(effect);
522
+ effect.__deps = [];
523
+ }
524
+ function track(target, key){
525
+ if(!active) return;
526
+ let depsMap = targetMap.get(target);
527
+ if(!depsMap){ depsMap = new Map(); targetMap.set(target, depsMap); }
528
+ let dep = depsMap.get(key);
529
+ if(!dep){ dep = new Set(); depsMap.set(key, dep); }
530
+ if(dep.has(active)) return;
531
+ dep.add(active);
532
+ if(!active.__deps) active.__deps = [];
533
+ active.__deps.push(dep);
534
+ }
535
+ function trigger(target, key){
536
+ const depsMap = targetMap.get(target);
537
+ if(!depsMap) return;
538
+ const effects = new Set();
539
+ const add = function(k){
540
+ const dep = depsMap.get(k);
541
+ if(dep) dep.forEach(function(e){ effects.add(e); });
542
+ };
543
+ add(key);
544
+ add('*');
545
+ effects.forEach(function(e){ queueRun(e); });
546
+ }
547
+ function isObject(value){ return value !== null && typeof value === 'object'; }
548
+ function proxify(value){
549
+ if(!isObject(value)) return value;
550
+ if(proxyMap.has(value)) return proxyMap.get(value);
551
+ const proxy = new Proxy(value, {
552
+ get(target, key, receiver){
553
+ track(target, key);
554
+ const out = Reflect.get(target, key, receiver);
555
+ return isObject(out) ? proxify(out) : out;
556
+ },
557
+ set(target, key, next, receiver){
558
+ const prev = target[key];
559
+ const result = Reflect.set(target, key, next, receiver);
560
+ if(prev !== next) trigger(target, key);
561
+ if(Array.isArray(target) && key !== 'length') trigger(target, 'length');
562
+ return result;
563
+ },
564
+ deleteProperty(target, key){
565
+ const had = Object.prototype.hasOwnProperty.call(target, key);
566
+ const result = Reflect.deleteProperty(target, key);
567
+ if(had) trigger(target, key);
568
+ return result;
569
+ }
570
+ });
571
+ proxyMap.set(value, proxy);
572
+ return proxy;
573
+ }
574
+ function effect(fn){
575
+ const run = function(){
576
+ cleanup(run);
577
+ active = run;
578
+ try { fn(); } finally { active = null; }
579
+ };
580
+ run.__deps = [];
581
+ run();
582
+ return function(){ cleanup(run); };
583
+ }
584
+ function state(initial){ return proxify(initial); }
585
+ function replace(_prev, next){ return proxify(next); }
586
+ g.__kuratchiReactive = { state, effect, replace };
587
+ })(window);`;
461
588
  const actionScript = `<script>${options.isDev ? bridgeSource : compactInlineJs(bridgeSource)}</script>`;
589
+ const reactiveRuntimeScript = `<script>${options.isDev ? reactiveRuntimeSource : compactInlineJs(reactiveRuntimeSource)}</script>`;
590
+ if (source.includes('</head>')) {
591
+ source = source.replace('</head>', reactiveRuntimeScript + '\n</head>');
592
+ }
593
+ else {
594
+ source = reactiveRuntimeScript + '\n' + source;
595
+ }
462
596
  source = source.replace('</body>', actionScript + '\n</body>');
463
597
  // Parse layout for <script> block (component imports + data vars)
464
598
  const layoutParsed = parseFile(source);
465
599
  const hasLayoutScript = layoutParsed.script && (Object.keys(layoutParsed.componentImports).length > 0 || layoutParsed.hasLoad);
466
600
  if (hasLayoutScript) {
467
- // Dynamic layout — has component imports and/or data declarations
601
+ // Dynamic layout â€" has component imports and/or data declarations
468
602
  // Compile component imports from layout
469
603
  for (const [pascalName, fileName] of Object.entries(layoutParsed.componentImports)) {
470
604
  compileComponent(fileName);
471
605
  layoutComponentNames.set(pascalName, fileName);
472
606
  }
473
607
  // Replace <slot></slot> with content parameter injection
474
- let layoutTemplate = layoutParsed.template.replace(/<slot\s*><\/slot>/g, '{=html __content}');
475
- layoutTemplate = layoutTemplate.replace(/<slot\s*\/>/g, '{=html __content}');
608
+ let layoutTemplate = layoutParsed.template.replace(/<slot\s*><\/slot>/g, '{@raw __content}');
609
+ layoutTemplate = layoutTemplate.replace(/<slot\s*\/>/g, '{@raw __content}');
476
610
  // Build layout action names so action={fn} works in layouts
477
611
  const layoutActionNames = new Set(layoutParsed.actionFunctions);
478
612
  // Compile the layout template with component + action support
@@ -500,7 +634,7 @@ export function compile(options) {
500
634
  }`;
501
635
  }
502
636
  else {
503
- // Static layout — no components, use fast string split (original behavior)
637
+ // Static layout â€" no components, use fast string split (original behavior)
504
638
  const slotMarker = '<slot></slot>';
505
639
  const slotIdx = source.indexOf(slotMarker);
506
640
  if (slotIdx === -1) {
@@ -513,7 +647,7 @@ export function compile(options) {
513
647
  }
514
648
  }
515
649
  // Custom error pages: src/routes/NNN.html (e.g. 404.html, 500.html, 401.html, 403.html)
516
- // Only compiled if the user explicitly creates them — otherwise the framework's built-in default is used
650
+ // Only compiled if the user explicitly creates them â€" otherwise the framework's built-in default is used
517
651
  const compiledErrorPages = new Map();
518
652
  for (const file of fs.readdirSync(routesDir)) {
519
653
  const match = file.match(/^(\d{3})\.html$/);
@@ -573,11 +707,125 @@ export function compile(options) {
573
707
  }
574
708
  }
575
709
  }
710
+ const resolveExistingModuleFile = (absBase) => {
711
+ const candidates = [
712
+ absBase,
713
+ absBase + '.ts',
714
+ absBase + '.js',
715
+ absBase + '.mjs',
716
+ absBase + '.cjs',
717
+ path.join(absBase, 'index.ts'),
718
+ path.join(absBase, 'index.js'),
719
+ path.join(absBase, 'index.mjs'),
720
+ path.join(absBase, 'index.cjs'),
721
+ ];
722
+ for (const candidate of candidates) {
723
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile())
724
+ return candidate;
725
+ }
726
+ return null;
727
+ };
728
+ const toModuleSpecifier = (fromFileAbs, toFileAbs) => {
729
+ let rel = path.relative(path.dirname(fromFileAbs), toFileAbs).replace(/\\/g, '/');
730
+ if (!rel.startsWith('.'))
731
+ rel = './' + rel;
732
+ return rel;
733
+ };
734
+ const transformedServerModules = new Map();
735
+ const modulesOutDir = path.join(projectDir, '.kuratchi', 'modules');
736
+ const resolveDoProxyTarget = (absPath) => {
737
+ const normalizedNoExt = absPath.replace(/\\/g, '/').replace(/\.[^.\/]+$/, '');
738
+ const proxyNoExt = doHandlerProxyPaths.get(normalizedNoExt);
739
+ if (!proxyNoExt)
740
+ return null;
741
+ return resolveExistingModuleFile(proxyNoExt) ?? (fs.existsSync(proxyNoExt + '.js') ? proxyNoExt + '.js' : null);
742
+ };
743
+ const resolveImportTarget = (importerAbs, spec) => {
744
+ if (spec.startsWith('$')) {
745
+ const slashIdx = spec.indexOf('/');
746
+ const folder = slashIdx === -1 ? spec.slice(1) : spec.slice(1, slashIdx);
747
+ const rest = slashIdx === -1 ? '' : spec.slice(slashIdx + 1);
748
+ if (!folder)
749
+ return null;
750
+ const abs = path.join(srcDir, folder, rest);
751
+ return resolveExistingModuleFile(abs) ?? abs;
752
+ }
753
+ if (spec.startsWith('.')) {
754
+ const abs = path.resolve(path.dirname(importerAbs), spec);
755
+ return resolveExistingModuleFile(abs) ?? abs;
756
+ }
757
+ return null;
758
+ };
759
+ const transformServerModule = (entryAbsPath) => {
760
+ const resolved = resolveExistingModuleFile(entryAbsPath) ?? entryAbsPath;
761
+ const normalized = resolved.replace(/\\/g, '/');
762
+ const cached = transformedServerModules.get(normalized);
763
+ if (cached)
764
+ return cached;
765
+ const relFromProject = path.relative(projectDir, resolved);
766
+ const outPath = path.join(modulesOutDir, relFromProject);
767
+ transformedServerModules.set(normalized, outPath);
768
+ const outDir = path.dirname(outPath);
769
+ if (!fs.existsSync(outDir))
770
+ fs.mkdirSync(outDir, { recursive: true });
771
+ if (!/\.(ts|js|mjs|cjs)$/i.test(resolved) || !fs.existsSync(resolved)) {
772
+ const passthrough = resolved;
773
+ transformedServerModules.set(normalized, passthrough);
774
+ return passthrough;
775
+ }
776
+ const source = fs.readFileSync(resolved, 'utf-8');
777
+ const rewriteSpecifier = (spec) => {
778
+ const target = resolveImportTarget(resolved, spec);
779
+ if (!target)
780
+ return spec;
781
+ const doProxyTarget = resolveDoProxyTarget(target);
782
+ if (doProxyTarget)
783
+ return toModuleSpecifier(outPath, doProxyTarget);
784
+ const normalizedTarget = target.replace(/\\/g, '/');
785
+ const inProject = normalizedTarget.startsWith(projectDir.replace(/\\/g, '/') + '/');
786
+ if (!inProject)
787
+ return spec;
788
+ const targetResolved = resolveExistingModuleFile(target) ?? target;
789
+ if (!/\.(ts|js|mjs|cjs)$/i.test(targetResolved))
790
+ return spec;
791
+ const rewrittenTarget = transformServerModule(targetResolved);
792
+ return toModuleSpecifier(outPath, rewrittenTarget);
793
+ };
794
+ let rewritten = source.replace(/(from\s+)(['"])([^'"]+)\2/g, (_m, p1, q, spec) => {
795
+ return `${p1}${q}${rewriteSpecifier(spec)}${q}`;
796
+ });
797
+ rewritten = rewritten.replace(/(import\s*\(\s*)(['"])([^'"]+)\2(\s*\))/g, (_m, p1, q, spec, p4) => {
798
+ return `${p1}${q}${rewriteSpecifier(spec)}${q}${p4}`;
799
+ });
800
+ writeIfChanged(outPath, rewritten);
801
+ return outPath;
802
+ };
803
+ const resolveCompiledImportPath = (origPath, importerDir, outFileDir) => {
804
+ const isBareModule = !origPath.startsWith('.') && !origPath.startsWith('/') && !origPath.startsWith('$');
805
+ if (isBareModule)
806
+ return origPath;
807
+ let absImport;
808
+ if (origPath.startsWith('$')) {
809
+ const slashIdx = origPath.indexOf('/');
810
+ const folder = slashIdx === -1 ? origPath.slice(1) : origPath.slice(1, slashIdx);
811
+ const rest = slashIdx === -1 ? '' : origPath.slice(slashIdx + 1);
812
+ absImport = path.join(srcDir, folder, rest);
813
+ }
814
+ else {
815
+ absImport = path.resolve(importerDir, origPath);
816
+ }
817
+ const doProxyTarget = resolveDoProxyTarget(absImport);
818
+ const target = doProxyTarget ?? transformServerModule(absImport);
819
+ let relPath = path.relative(outFileDir, target).replace(/\\/g, '/');
820
+ if (!relPath.startsWith('.'))
821
+ relPath = './' + relPath;
822
+ return relPath;
823
+ };
576
824
  // Parse and compile each route
577
825
  const compiledRoutes = [];
578
826
  const allImports = [];
579
827
  let moduleCounter = 0;
580
- // Layout server import resolution — resolve non-component imports to module IDs
828
+ // Layout server import resolution â€" resolve non-component imports to module IDs
581
829
  let isLayoutAsync = false;
582
830
  let compiledLayoutActions = null;
583
831
  if (compiledLayout && fs.existsSync(path.join(routesDir, 'layout.html'))) {
@@ -592,39 +840,7 @@ export function compile(options) {
592
840
  if (!pathMatch)
593
841
  continue;
594
842
  const origPath = pathMatch[1];
595
- const isBareModule = !origPath.startsWith('.') && !origPath.startsWith('/') && !origPath.startsWith('$');
596
- let importPath;
597
- if (isBareModule) {
598
- importPath = origPath;
599
- }
600
- else {
601
- let absImport;
602
- if (origPath.startsWith('$')) {
603
- const slashIdx = origPath.indexOf('/');
604
- const folder = origPath.slice(1, slashIdx);
605
- const rest = origPath.slice(slashIdx + 1);
606
- absImport = path.join(srcDir, folder, rest);
607
- // Redirect DO handler imports to generated proxy modules
608
- const doProxyPath = doHandlerProxyPaths.get(absImport.replace(/\\/g, '/'));
609
- if (doProxyPath) {
610
- absImport = doProxyPath;
611
- }
612
- }
613
- else {
614
- absImport = path.resolve(layoutFileDir, origPath);
615
- }
616
- let relPath = path.relative(outFileDir, absImport).replace(/\\/g, '/');
617
- if (!relPath.startsWith('.'))
618
- relPath = './' + relPath;
619
- let resolvedExt = '';
620
- for (const ext of ['.ts', '.js', '/index.ts', '/index.js']) {
621
- if (fs.existsSync(absImport + ext)) {
622
- resolvedExt = ext;
623
- break;
624
- }
625
- }
626
- importPath = relPath + resolvedExt;
627
- }
843
+ const importPath = resolveCompiledImportPath(origPath, layoutFileDir, outFileDir);
628
844
  const moduleId = `__m${moduleCounter++}`;
629
845
  allImports.push(`import * as ${moduleId} from '${importPath}';`);
630
846
  const namesMatch = imp.match(/import\s*\{([^}]+)\}/);
@@ -665,7 +881,7 @@ export function compile(options) {
665
881
  }
666
882
  }
667
883
  }
668
- // Detect if the compiled layout uses await → make it async
884
+ // Detect if the compiled layout uses await â†' make it async
669
885
  isLayoutAsync = /\bawait\b/.test(compiledLayout);
670
886
  if (isLayoutAsync) {
671
887
  compiledLayout = compiledLayout.replace(/^function __layout\(/, 'async function __layout(');
@@ -674,72 +890,62 @@ export function compile(options) {
674
890
  for (let i = 0; i < routeFiles.length; i++) {
675
891
  const rf = routeFiles[i];
676
892
  const fullPath = path.join(routesDir, rf.file);
893
+ const pattern = filePathToPattern(rf.name);
894
+ // -- API route (index.ts / index.js) --
895
+ if (rf.type === 'api') {
896
+ const outFileDir = path.join(projectDir, '.kuratchi');
897
+ // Resolve the route file's absolute path through transformServerModule
898
+ // so that $-prefixed imports inside it get rewritten correctly
899
+ const absRoutePath = transformServerModule(fullPath);
900
+ let importPath = path.relative(outFileDir, absRoutePath).replace(/\\/g, '/');
901
+ if (!importPath.startsWith('.'))
902
+ importPath = './' + importPath;
903
+ const moduleId = `__m${moduleCounter++}`;
904
+ allImports.push(`import * as ${moduleId} from '${importPath}';`);
905
+ // Scan the source for exported method handlers (only include what exists)
906
+ const apiSource = fs.readFileSync(fullPath, 'utf-8');
907
+ const allMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
908
+ const exportedMethods = allMethods.filter(m => {
909
+ // Match: export function GET, export async function GET, export { ... as GET }
910
+ const fnPattern = new RegExp(`export\\s+(async\\s+)?function\\s+${m}\\b`);
911
+ const reExportPattern = new RegExp(`export\\s*\\{[^}]*\\b\\w+\\s+as\\s+${m}\\b`);
912
+ const namedExportPattern = new RegExp(`export\\s*\\{[^}]*\\b${m}\\b`);
913
+ return fnPattern.test(apiSource) || reExportPattern.test(apiSource) || namedExportPattern.test(apiSource);
914
+ });
915
+ const methodEntries = exportedMethods
916
+ .map(m => `${m}: ${moduleId}.${m}`)
917
+ .join(', ');
918
+ compiledRoutes.push(`{ pattern: '${pattern}', __api: true, ${methodEntries} }`);
919
+ continue;
920
+ }
921
+ // ── Page route (page.html) ──
677
922
  const source = fs.readFileSync(fullPath, 'utf-8');
678
923
  const parsed = parseFile(source);
679
- const pattern = filePathToPattern(rf.name);
680
- // Build a mapping: functionName → moduleId for all imports in this route
924
+ // Build a mapping: functionName â†' moduleId for all imports in this route
681
925
  const fnToModule = {};
682
926
  const outFileDir = path.join(projectDir, '.kuratchi');
683
- if (parsed.serverImports.length > 0) {
927
+ const neededServerFns = new Set([
928
+ ...parsed.actionFunctions,
929
+ ...parsed.pollFunctions,
930
+ ...parsed.dataGetQueries.map((q) => q.fnName),
931
+ ]);
932
+ const routeServerImports = parsed.serverImports.length > 0
933
+ ? parsed.serverImports
934
+ : filterClientImportsForServer(parsed.clientImports, neededServerFns);
935
+ if (routeServerImports.length > 0) {
684
936
  const routeFileDir = path.dirname(fullPath);
685
- for (const imp of parsed.serverImports) {
937
+ for (const imp of routeServerImports) {
686
938
  const pathMatch = imp.match(/from\s+['"]([^'"]+)['"]/);
687
939
  if (!pathMatch)
688
940
  continue;
689
941
  const origPath = pathMatch[1];
690
- // Bare module specifiers (packages) — pass through as-is
691
- const isBareModule = !origPath.startsWith('.') && !origPath.startsWith('/') && !origPath.startsWith('$');
692
- let importPath;
693
- if (isBareModule) {
694
- // Package import: @kuratchi/auth, KuratchiJS, cloudflare:workers, etc.
695
- importPath = origPath;
696
- }
697
- else {
698
- let absImport;
699
- if (origPath.startsWith('$')) {
700
- // Dynamic $folder/ alias → src/folder/
701
- const slashIdx = origPath.indexOf('/');
702
- const folder = origPath.slice(1, slashIdx);
703
- const rest = origPath.slice(slashIdx + 1);
704
- absImport = path.join(srcDir, folder, rest);
705
- // Redirect DO handler imports to generated proxy modules
706
- const doProxyPath = doHandlerProxyPaths.get(absImport.replace(/\\/g, '/'));
707
- if (doProxyPath) {
708
- absImport = doProxyPath;
709
- }
710
- }
711
- else {
712
- // Resolve the import relative to the route file
713
- absImport = path.resolve(routeFileDir, origPath);
714
- }
715
- // Make it relative to the output directory
716
- let relPath = path.relative(outFileDir, absImport).replace(/\\/g, '/');
717
- if (!relPath.startsWith('.'))
718
- relPath = './' + relPath;
719
- // Check if the resolved file exists (try .ts, .js extensions)
720
- let resolvedExt = '';
721
- for (const ext of ['.ts', '.js', '/index.ts', '/index.js']) {
722
- if (fs.existsSync(absImport + ext)) {
723
- resolvedExt = ext;
724
- break;
725
- }
726
- }
727
- importPath = relPath + resolvedExt;
728
- }
942
+ // Bare module specifiers (packages) â€" pass through as-is
943
+ const importPath = resolveCompiledImportPath(origPath, routeFileDir, outFileDir);
729
944
  const moduleId = `__m${moduleCounter++}`;
730
945
  allImports.push(`import * as ${moduleId} from '${importPath}';`);
731
946
  // Extract named imports and map them to this module
732
- const namesMatch = imp.match(/import\s*\{([^}]+)\}/);
733
- if (namesMatch) {
734
- const names = namesMatch[1]
735
- .split(',')
736
- .map(n => n.trim())
737
- .filter(Boolean)
738
- .map(n => {
739
- const parts = n.split(/\s+as\s+/).map(p => p.trim()).filter(Boolean);
740
- return parts[1] || parts[0] || '';
741
- })
742
- .filter(Boolean);
947
+ const names = parseNamedImportBindings(imp);
948
+ if (names.length > 0) {
743
949
  for (const name of names) {
744
950
  fnToModule[name] = moduleId;
745
951
  }
@@ -752,7 +958,7 @@ export function compile(options) {
752
958
  }
753
959
  }
754
960
  // Build per-route component names from explicit imports
755
- // componentImports: { StatCard: 'stat-card' } → componentNames maps PascalCase → fileName
961
+ // componentImports: { StatCard: 'stat-card' } â†' componentNames maps PascalCase â†' fileName
756
962
  const routeComponentNames = new Map();
757
963
  for (const [pascalName, fileName] of Object.entries(parsed.componentImports)) {
758
964
  // Compile the component on first use
@@ -767,22 +973,22 @@ export function compile(options) {
767
973
  // We then scan the route template for that component's usage and extract the bound values.
768
974
  for (const [pascalName, compFileName] of routeComponentNames.entries()) {
769
975
  const actionPropNames = componentActionCache.get(compFileName);
770
- if (!actionPropNames || actionPropNames.size === 0)
771
- continue;
772
976
  // Find all usages of <PascalName ...> in the route template and extract prop bindings.
773
977
  // Match <ComponentName ... propName={value} ... > across multiple lines.
774
978
  const compTagRegex = new RegExp(`<${pascalName}\\b([\\s\\S]*?)/>`, 'g');
775
979
  for (const tagMatch of parsed.template.matchAll(compTagRegex)) {
776
980
  const attrs = tagMatch[1];
777
- for (const propName of actionPropNames) {
778
- // Find propName={identifier} binding
779
- const propRegex = new RegExp(`\\b${propName}=\\{([A-Za-z_$][\\w$]*)\\}`);
780
- const propMatch = attrs.match(propRegex);
781
- if (propMatch) {
782
- const routeFnName = propMatch[1];
783
- // Only add if this function is actually imported by the route
784
- if (routeFnName in fnToModule && !parsed.actionFunctions.includes(routeFnName)) {
785
- parsed.actionFunctions.push(routeFnName);
981
+ if (actionPropNames && actionPropNames.size > 0) {
982
+ for (const propName of actionPropNames) {
983
+ // Find propName={identifier} binding
984
+ const propRegex = new RegExp(`\\b${propName}=\\{([A-Za-z_$][\\w$]*)\\}`);
985
+ const propMatch = attrs.match(propRegex);
986
+ if (propMatch) {
987
+ const routeFnName = propMatch[1];
988
+ // Only add if this function is actually imported by the route
989
+ if (routeFnName in fnToModule && !parsed.actionFunctions.includes(routeFnName)) {
990
+ parsed.actionFunctions.push(routeFnName);
991
+ }
786
992
  }
787
993
  }
788
994
  }
@@ -791,7 +997,7 @@ export function compile(options) {
791
997
  // Compile template to render function body (pass component names and action names)
792
998
  // An identifier is a valid server action if it is either:
793
999
  // 1. Directly imported (present in fnToModule), or
794
- // 2. A top-level script declaration (present in dataVars) — covers cases like
1000
+ // 2. A top-level script declaration (present in dataVars) â€" covers cases like
795
1001
  // `const fn = importedFn` or `async function fn() {}` where the binding
796
1002
  // is locally declared but delegates to an imported function.
797
1003
  const dataVarsSet = new Set(parsed.dataVars);
@@ -869,7 +1075,16 @@ export function compile(options) {
869
1075
  // Collect only the components that were actually imported by routes
870
1076
  const compiledComponents = Array.from(compiledComponentCache.values());
871
1077
  // Generate the routes module
872
- const runtimeImportPath = resolveRuntimeImportPath(projectDir);
1078
+ const rawRuntimeImportPath = resolveRuntimeImportPath(projectDir);
1079
+ let runtimeImportPath;
1080
+ if (rawRuntimeImportPath) {
1081
+ // Resolve the runtime file's absolute path and pass it through transformServerModule
1082
+ // so that $durable-objects/* and other project imports get rewritten to their proxies.
1083
+ const runtimeAbs = path.resolve(path.join(projectDir, '.kuratchi'), rawRuntimeImportPath);
1084
+ const transformedRuntimePath = transformServerModule(runtimeAbs);
1085
+ const outFile = options.outFile ?? path.join(projectDir, '.kuratchi', 'routes.js');
1086
+ runtimeImportPath = toModuleSpecifier(outFile, transformedRuntimePath);
1087
+ }
873
1088
  const hasRuntime = !!runtimeImportPath;
874
1089
  const output = generateRoutesModule({
875
1090
  projectDir,
@@ -887,7 +1102,7 @@ export function compile(options) {
887
1102
  isLayoutAsync,
888
1103
  compiledLayoutActions,
889
1104
  hasRuntime,
890
- runtimeImportPath: runtimeImportPath ?? undefined,
1105
+ runtimeImportPath,
891
1106
  });
892
1107
  // Write to .kuratchi/routes.js
893
1108
  const outFile = options.outFile ?? path.join(projectDir, '.kuratchi', 'routes.js');
@@ -919,7 +1134,7 @@ export function compile(options) {
919
1134
  writeIfChanged(workerFile, workerLines.join('\n'));
920
1135
  return workerFile;
921
1136
  }
922
- // ── Helpers ─────────────────────────────────────────────────────────────
1137
+ // â"€â"€ Helpers â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
923
1138
  /**
924
1139
  * Write a file only if its content has changed.
925
1140
  * Prevents unnecessary filesystem events that would retrigger wrangler's file watcher.
@@ -1013,7 +1228,7 @@ function readUiTheme(projectDir) {
1013
1228
  console.warn('[kuratchi] ui.theme: "default" configured but @kuratchi/ui theme.css not found');
1014
1229
  return null;
1015
1230
  }
1016
- // Custom path — resolve relative to project root
1231
+ // Custom path â€" resolve relative to project root
1017
1232
  const customPath = path.resolve(projectDir, themeValue);
1018
1233
  if (fs.existsSync(customPath)) {
1019
1234
  return fs.readFileSync(customPath, 'utf-8');
@@ -1031,7 +1246,7 @@ function resolvePackageComponent(projectDir, pkgName, componentFile) {
1031
1246
  if (fs.existsSync(nmPath))
1032
1247
  return nmPath;
1033
1248
  // 2. Try workspace layout: project is in apps/X or packages/X, sibling packages in packages/
1034
- // @kuratchi/ui → kuratchi-ui (convention: scope stripped, slash → dash)
1249
+ // @kuratchi/ui â†' kuratchi-ui (convention: scope stripped, slash â†' dash)
1035
1250
  const pkgDirName = pkgName.replace(/^@/, '').replace(/\//g, '-');
1036
1251
  const workspaceRoot = path.resolve(projectDir, '../..');
1037
1252
  const wsPath = path.join(workspaceRoot, 'packages', pkgDirName, 'src', 'lib', componentFile + '.html');
@@ -1069,32 +1284,49 @@ function discoverRoutes(routesDir) {
1069
1284
  for (const entry of entries) {
1070
1285
  if (entry.isDirectory()) {
1071
1286
  const childPrefix = prefix ? `${prefix}/${entry.name}` : entry.name;
1072
- // Folder-based route: routes/db/page.html → /db
1287
+ // Folder-based page route: routes/db/page.html /db
1073
1288
  const pageFile = path.join(dir, entry.name, 'page.html');
1074
1289
  if (fs.existsSync(pageFile)) {
1075
1290
  const routeFile = `${childPrefix}/page.html`;
1076
1291
  if (!registered.has(routeFile)) {
1077
1292
  registered.add(routeFile);
1078
- results.push({ file: routeFile, name: childPrefix, layouts: getLayoutsForPrefix(childPrefix) });
1293
+ results.push({ file: routeFile, name: childPrefix, layouts: getLayoutsForPrefix(childPrefix), type: 'page' });
1294
+ }
1295
+ }
1296
+ // Folder-based API route: routes/api/v1/health/index.ts -> /api/v1/health
1297
+ const apiFile = ['index.ts', 'index.js'].find(f => fs.existsSync(path.join(dir, entry.name, f)));
1298
+ if (apiFile && !fs.existsSync(pageFile)) {
1299
+ const routeFile = `${childPrefix}/${apiFile}`;
1300
+ if (!registered.has(routeFile)) {
1301
+ registered.add(routeFile);
1302
+ results.push({ file: routeFile, name: childPrefix, layouts: [], type: 'api' });
1079
1303
  }
1080
1304
  }
1081
1305
  // Always recurse into subdirectory (for nested routes like /admin/roles)
1082
1306
  walk(path.join(dir, entry.name), childPrefix);
1083
1307
  }
1084
1308
  else if (entry.name === 'layout.html' || entry.name === '404.html' || entry.name === '500.html') {
1085
- // Skip — layout.html is the app layout, 404/500 are error pages, not routes
1309
+ // Skip layout.html is the app layout, 404/500 are error pages, not routes
1086
1310
  continue;
1087
1311
  }
1312
+ else if (entry.name === 'index.ts' || entry.name === 'index.js') {
1313
+ // API route file in current directory -> index API route for this prefix
1314
+ const routeFile = prefix ? `${prefix}/${entry.name}` : entry.name;
1315
+ if (!registered.has(routeFile)) {
1316
+ registered.add(routeFile);
1317
+ results.push({ file: routeFile, name: prefix || 'index', layouts: [], type: 'api' });
1318
+ }
1319
+ }
1088
1320
  else if (entry.name === 'page.html') {
1089
- // page.html in current directory → index route for this prefix
1321
+ // page.html in current directory index route for this prefix
1090
1322
  const routeFile = prefix ? `${prefix}/page.html` : 'page.html';
1091
1323
  if (!registered.has(routeFile)) {
1092
1324
  registered.add(routeFile);
1093
- results.push({ file: routeFile, name: prefix || 'index', layouts: getLayoutsForPrefix(prefix) });
1325
+ results.push({ file: routeFile, name: prefix || 'index', layouts: getLayoutsForPrefix(prefix), type: 'page' });
1094
1326
  }
1095
1327
  }
1096
1328
  else if (entry.name.endsWith('.html') && entry.name !== 'page.html') {
1097
- // File-based route: routes/about.html → /about (fallback)
1329
+ // File-based route: routes/about.html /about (fallback)
1098
1330
  const name = prefix
1099
1331
  ? `${prefix}/${entry.name.replace('.html', '')}`
1100
1332
  : entry.name.replace('.html', '');
@@ -1102,6 +1334,7 @@ function discoverRoutes(routesDir) {
1102
1334
  file: prefix ? `${prefix}/${entry.name}` : entry.name,
1103
1335
  name,
1104
1336
  layouts: getLayoutsForPrefix(prefix),
1337
+ type: 'page',
1105
1338
  });
1106
1339
  }
1107
1340
  }
@@ -1120,18 +1353,33 @@ function buildRouteObject(opts) {
1120
1353
  const hasFns = Object.keys(fnToModule).length > 0;
1121
1354
  const parts = [];
1122
1355
  parts.push(` pattern: '${pattern}'`);
1123
- // Load function — generated from the script block's top-level code + data-get queries
1356
+ const queryVars = parsed.dataGetQueries?.map((q) => q.asName) ?? [];
1357
+ let scriptBody = parsed.script
1358
+ ? parsed.script.replace(/^\s*import[\s\S]*?from\s+['"][^'"]+['"]\s*;?/gm, '').trim()
1359
+ : '';
1360
+ for (const [fnName, moduleId] of Object.entries(fnToModule)) {
1361
+ if (!/^[A-Za-z_$][\w$]*$/.test(fnName))
1362
+ continue;
1363
+ const callRegex = new RegExp(`\\b${fnName}\\s*\\(`, 'g');
1364
+ scriptBody = scriptBody.replace(callRegex, `${moduleId}.${fnName}(`);
1365
+ }
1366
+ const scriptUsesAwait = /\bawait\b/.test(scriptBody);
1367
+ const scriptReturnVars = parsed.script
1368
+ ? parsed.dataVars.filter((v) => !queryVars.includes(v))
1369
+ : [];
1370
+ // Load function â€" internal server prepass for async route script bodies
1371
+ // and data-get query state hydration.
1124
1372
  const hasDataGetQueries = Array.isArray(parsed.dataGetQueries) && parsed.dataGetQueries.length > 0;
1125
- if ((parsed.hasLoad && parsed.script) || hasDataGetQueries) {
1126
- // Get script body (everything except imports)
1127
- let body = parsed.script
1128
- ? parsed.script.replace(/^\s*import[\s\S]*?from\s+['"][^'"]+['"]\s*;?/gm, '').trim()
1129
- : '';
1373
+ if ((scriptBody && scriptUsesAwait) || hasDataGetQueries) {
1374
+ let loadBody = '';
1375
+ if (scriptBody && scriptUsesAwait) {
1376
+ loadBody = scriptBody;
1377
+ }
1130
1378
  // Inject data-get query state blocks into load scope.
1131
1379
  // Each query exposes:
1132
1380
  // { state, loading, error, data, empty, success }
1381
+ const queries = parsed.dataGetQueries;
1133
1382
  if (hasDataGetQueries) {
1134
- const queries = parsed.dataGetQueries;
1135
1383
  const queryLines = [];
1136
1384
  for (const q of queries) {
1137
1385
  const fnName = q.fnName;
@@ -1154,25 +1402,13 @@ function buildRouteObject(opts) {
1154
1402
  queryLines.push(` }`);
1155
1403
  queryLines.push(`}`);
1156
1404
  }
1157
- body = [body, queryLines.join('\n')].filter(Boolean).join('\n');
1405
+ loadBody = [loadBody, queryLines.join('\n')].filter(Boolean).join('\n');
1158
1406
  }
1159
- // Rewrite imported function calls: fnName( → __mN.fnName(
1160
- // Rewrite imported function calls to use the module namespace
1161
- for (const [fnName, moduleId] of Object.entries(fnToModule)) {
1162
- if (!/^[A-Za-z_$][\w$]*$/.test(fnName))
1163
- continue;
1164
- const callRegex = new RegExp(`\\b${fnName}\\s*\\(`, 'g');
1165
- body = body.replace(callRegex, `${moduleId}.${fnName}(`);
1166
- }
1167
- // Determine if body uses await
1168
- const isAsync = /\bawait\b/.test(body) || hasDataGetQueries;
1169
- // Return an object with all declared data variables
1170
- const returnObj = parsed.dataVars.length > 0
1171
- ? `\n return { ${parsed.dataVars.join(', ')} };`
1172
- : '';
1173
- parts.push(` ${isAsync ? 'async ' : ''}load(params = {}) {\n ${body}${returnObj}\n }`);
1407
+ const loadReturnVars = [...scriptReturnVars, ...queryVars];
1408
+ const returnObj = loadReturnVars.length > 0 ? `\n return { ${loadReturnVars.join(', ')} };` : '';
1409
+ parts.push(` async load(params = {}) {\n ${loadBody}${returnObj}\n }`);
1174
1410
  }
1175
- // Actions — functions referenced via action={fn} in the template
1411
+ // Actions â€" functions referenced via action={fn} in the template
1176
1412
  if (hasFns && parsed.actionFunctions.length > 0) {
1177
1413
  const actionEntries = parsed.actionFunctions
1178
1414
  .map(fn => {
@@ -1182,7 +1418,7 @@ function buildRouteObject(opts) {
1182
1418
  .join(', ');
1183
1419
  parts.push(` actions: { ${actionEntries} }`);
1184
1420
  }
1185
- // RPC — functions referenced via data-poll={fn(args)} in the template
1421
+ // RPC â€" functions referenced via data-poll={fn(args)} in the template
1186
1422
  if (hasFns && parsed.pollFunctions.length > 0) {
1187
1423
  const rpcEntries = parsed.pollFunctions
1188
1424
  .map(fn => {
@@ -1193,12 +1429,21 @@ function buildRouteObject(opts) {
1193
1429
  .join(', ');
1194
1430
  parts.push(` rpc: { ${rpcEntries} }`);
1195
1431
  }
1196
- // Render function — template compiled to JS with native flow control
1432
+ // Render function â€" template compiled to JS with native flow control
1197
1433
  // Destructure data vars so templates reference them directly (e.g., {todos} not {data.todos})
1198
- // Always include __error so templates can show form action errors via {__error}
1199
- const allVars = [...parsed.dataVars];
1200
- if (!allVars.includes('__error'))
1201
- allVars.push('__error');
1434
+ // Auto-inject action state objects so templates can reference signIn.error, signIn.loading, etc.
1435
+ const renderPrelude = (scriptBody && !scriptUsesAwait) ? scriptBody : '';
1436
+ const allVars = [...queryVars];
1437
+ if (scriptUsesAwait) {
1438
+ for (const v of scriptReturnVars) {
1439
+ if (!allVars.includes(v))
1440
+ allVars.push(v);
1441
+ }
1442
+ }
1443
+ for (const fn of parsed.actionFunctions) {
1444
+ if (!allVars.includes(fn))
1445
+ allVars.push(fn);
1446
+ }
1202
1447
  if (!allVars.includes('params'))
1203
1448
  allVars.push('params');
1204
1449
  if (!allVars.includes('breadcrumbs'))
@@ -1213,7 +1458,7 @@ function buildRouteObject(opts) {
1213
1458
  finalRenderBody = [lines[0], ...styleLines, ...lines.slice(1)].join('\n');
1214
1459
  }
1215
1460
  parts.push(` render(data) {
1216
- ${destructure}${finalRenderBody}
1461
+ ${destructure}${renderPrelude ? renderPrelude + '\n ' : ''}${finalRenderBody}
1217
1462
  return __html;
1218
1463
  }`);
1219
1464
  return ` {\n${parts.join(',\n')}\n }`;
@@ -1227,7 +1472,7 @@ function readOrmConfig(projectDir) {
1227
1472
  if (!ormBlock)
1228
1473
  return [];
1229
1474
  // Extract schema imports: import { todoSchema } from './src/schemas/todo';
1230
- const importMap = new Map(); // exportName → importPath
1475
+ const importMap = new Map(); // exportName â†' importPath
1231
1476
  const importRegex = /import\s*\{\s*([^}]+)\s*\}\s*from\s*['"]([^'"]+)['"]/g;
1232
1477
  let m;
1233
1478
  while ((m = importRegex.exec(source)) !== null) {
@@ -1364,7 +1609,7 @@ function readDoConfig(projectDir) {
1364
1609
  if (list.length > 0)
1365
1610
  entry.files = list;
1366
1611
  }
1367
- // (inject config removed — DO methods are org-scoped, no auto-injection needed)
1612
+ // (inject config removed â€" DO methods are org-scoped, no auto-injection needed)
1368
1613
  entries.push(entry);
1369
1614
  }
1370
1615
  // Match string shorthand: BINDING: 'ClassName' (skip bindings already found)
@@ -1482,9 +1727,9 @@ function discoverFilesWithSuffix(dir, suffix) {
1482
1727
  return out;
1483
1728
  }
1484
1729
  /**
1485
- * Scan for files that extend kuratchiDO.
1486
- * Primary discovery is recursive under `src/server` for files ending in `.do.ts`.
1487
- * Legacy fallback keeps `src/durable-objects/*.ts` compatible.
1730
+ * Scan DO handler files.
1731
+ * - Class mode: default class extends kuratchiDO
1732
+ * - Function mode: exported functions in *.do.ts files (compiler wraps into DO class)
1488
1733
  */
1489
1734
  function discoverDoHandlers(srcDir, doConfig, ormDatabases) {
1490
1735
  const serverDir = path.join(srcDir, 'server');
@@ -1512,14 +1757,15 @@ function discoverDoHandlers(srcDir, doConfig, ormDatabases) {
1512
1757
  for (const absPath of discoveredFiles) {
1513
1758
  const file = path.basename(absPath);
1514
1759
  const source = fs.readFileSync(absPath, 'utf-8');
1515
- // Check if this file extends kuratchiDO
1516
- if (!/extends\s+kuratchiDO\b/.test(source))
1760
+ const exportedFunctions = extractExportedFunctions(source);
1761
+ const hasClass = /extends\s+kuratchiDO\b/.test(source);
1762
+ if (!hasClass && exportedFunctions.length === 0)
1517
1763
  continue;
1518
- // Extract class name: export default class Sites extends kuratchiDO
1764
+ // Extract class name when class mode is used.
1519
1765
  const classMatch = source.match(/export\s+default\s+class\s+(\w+)\s+extends\s+kuratchiDO/);
1520
- if (!classMatch)
1766
+ const className = classMatch?.[1] ?? null;
1767
+ if (hasClass && !className)
1521
1768
  continue;
1522
- const className = classMatch[1];
1523
1769
  // Binding resolution:
1524
1770
  // 1) explicit static binding in class
1525
1771
  // 2) config-mapped file name (supports .do.ts convention)
@@ -1544,10 +1790,8 @@ function discoverDoHandlers(srcDir, doConfig, ormDatabases) {
1544
1790
  continue;
1545
1791
  if (!bindings.has(binding))
1546
1792
  continue;
1547
- // Extract class methods — find the class body and parse method declarations
1548
- const classMethods = extractClassMethods(source, className);
1549
- // Extract named exports (custom worker-side helpers)
1550
- const namedExports = extractNamedExports(source);
1793
+ // Extract class methods in class mode
1794
+ const classMethods = className ? extractClassMethods(source, className) : [];
1551
1795
  const fileName = file.replace(/\.ts$/, '');
1552
1796
  const existing = fileNameToAbsPath.get(fileName);
1553
1797
  if (existing && existing !== absPath) {
@@ -1558,9 +1802,10 @@ function discoverDoHandlers(srcDir, doConfig, ormDatabases) {
1558
1802
  fileName,
1559
1803
  absPath,
1560
1804
  binding,
1561
- className,
1805
+ mode: hasClass ? 'class' : 'function',
1806
+ className: className ?? undefined,
1562
1807
  classMethods,
1563
- namedExports,
1808
+ exportedFunctions,
1564
1809
  });
1565
1810
  }
1566
1811
  return handlers;
@@ -1652,40 +1897,33 @@ function extractClassMethods(source, className) {
1652
1897
  }
1653
1898
  return methods;
1654
1899
  }
1655
- /**
1656
- * Extract explicitly exported function/const names from a file.
1657
- */
1658
- function extractNamedExports(source) {
1659
- const exports = [];
1660
- // export async function name / export function name
1900
+ function extractExportedFunctions(source) {
1901
+ const out = [];
1661
1902
  const fnRegex = /export\s+(?:async\s+)?function\s+(\w+)/g;
1662
1903
  let m;
1663
1904
  while ((m = fnRegex.exec(source)) !== null)
1664
- exports.push(m[1]);
1665
- // export const name
1666
- const constRegex = /export\s+const\s+(\w+)/g;
1667
- while ((m = constRegex.exec(source)) !== null)
1668
- exports.push(m[1]);
1669
- return exports;
1905
+ out.push(m[1]);
1906
+ return out;
1670
1907
  }
1671
1908
  /**
1672
1909
  * Generate a proxy module for a DO handler file.
1673
1910
  *
1674
- * The proxy provides:
1675
- * - Auto-RPC function exports for each public class method
1676
- * - Re-exports of the user's custom named exports
1677
- *
1678
- * Methods that collide with custom exports are skipped (user's export wins).
1911
+ * The proxy provides auto-RPC function exports.
1912
+ * - Class mode: public class methods become RPC exports.
1913
+ * - Function mode: exported functions become RPC exports, excluding lifecycle hooks.
1679
1914
  */
1680
1915
  function generateHandlerProxy(handler, projectDir) {
1681
1916
  const doDir = path.join(projectDir, '.kuratchi', 'do');
1682
1917
  const origRelPath = path.relative(doDir, handler.absPath).replace(/\\/g, '/').replace(/\.ts$/, '.js');
1683
1918
  const handlerLocal = `__handler_${toSafeIdentifier(handler.fileName)}`;
1684
- const customSet = new Set(handler.namedExports);
1919
+ const lifecycle = new Set(['onInit', 'onAlarm', 'onMessage']);
1920
+ const rpcFunctions = handler.mode === 'function'
1921
+ ? handler.exportedFunctions.filter((n) => !lifecycle.has(n))
1922
+ : handler.classMethods.filter((m) => m.visibility === 'public').map((m) => m.name);
1685
1923
  const methods = handler.classMethods.map((m) => ({ ...m }));
1686
1924
  const methodMap = new Map(methods.map((m) => [m.name, m]));
1687
1925
  let changed = true;
1688
- while (changed) {
1926
+ while (changed && handler.mode === 'class') {
1689
1927
  changed = false;
1690
1928
  for (const m of methods) {
1691
1929
  if (m.hasWorkerContextCalls)
@@ -1700,15 +1938,56 @@ function generateHandlerProxy(handler, projectDir) {
1700
1938
  }
1701
1939
  }
1702
1940
  }
1703
- const publicMethods = methods.filter((m) => m.visibility === 'public').map((m) => m.name);
1704
- const workerContextMethods = methods
1705
- .filter((m) => m.visibility === 'public' && m.hasWorkerContextCalls)
1706
- .map((m) => m.name);
1707
- const asyncMethods = methods.filter((m) => m.isAsync).map((m) => m.name);
1941
+ const workerContextMethods = handler.mode === 'class'
1942
+ ? methods.filter((m) => m.visibility === 'public' && m.hasWorkerContextCalls).map((m) => m.name)
1943
+ : [];
1944
+ const asyncMethods = handler.mode === 'class'
1945
+ ? methods.filter((m) => m.isAsync).map((m) => m.name)
1946
+ : [];
1708
1947
  const lines = [
1709
- `// Auto-generated by KuratchiJS compiler — do not edit.`,
1948
+ `// Auto-generated by KuratchiJS compiler â€" do not edit.`,
1710
1949
  `import { __getDoStub } from '${RUNTIME_DO_IMPORT}';`,
1711
- `import ${handlerLocal} from '${origRelPath}';`,
1950
+ ...(handler.mode === 'class' ? [`import ${handlerLocal} from '${origRelPath}';`] : []),
1951
+ ``,
1952
+ `const __FD_TAG = '__kuratchi_form_data__';`,
1953
+ `function __isPlainObject(__v) {`,
1954
+ ` if (!__v || typeof __v !== 'object') return false;`,
1955
+ ` const __proto = Object.getPrototypeOf(__v);`,
1956
+ ` return __proto === Object.prototype || __proto === null;`,
1957
+ `}`,
1958
+ `function __encodeArg(__v, __seen = new WeakSet()) {`,
1959
+ ` if (typeof FormData !== 'undefined' && __v instanceof FormData) {`,
1960
+ ` return { [__FD_TAG]: Array.from(__v.entries()) };`,
1961
+ ` }`,
1962
+ ` if (Array.isArray(__v)) return __v.map((__x) => __encodeArg(__x, __seen));`,
1963
+ ` if (__isPlainObject(__v)) {`,
1964
+ ` if (__seen.has(__v)) throw new Error('[KuratchiJS] Circular object passed to DO RPC');`,
1965
+ ` __seen.add(__v);`,
1966
+ ` const __out = {};`,
1967
+ ` for (const [__k, __val] of Object.entries(__v)) __out[__k] = __encodeArg(__val, __seen);`,
1968
+ ` __seen.delete(__v);`,
1969
+ ` return __out;`,
1970
+ ` }`,
1971
+ ` return __v;`,
1972
+ `}`,
1973
+ `function __decodeArg(__v) {`,
1974
+ ` if (Array.isArray(__v)) return __v.map(__decodeArg);`,
1975
+ ` if (__isPlainObject(__v)) {`,
1976
+ ` const __obj = __v;`,
1977
+ ` if (__FD_TAG in __obj) {`,
1978
+ ` const __fd = new FormData();`,
1979
+ ` const __entries = Array.isArray(__obj[__FD_TAG]) ? __obj[__FD_TAG] : [];`,
1980
+ ` for (const __pair of __entries) {`,
1981
+ ` if (Array.isArray(__pair) && __pair.length >= 2) __fd.append(String(__pair[0]), __pair[1]);`,
1982
+ ` }`,
1983
+ ` return __fd;`,
1984
+ ` }`,
1985
+ ` const __out = {};`,
1986
+ ` for (const [__k, __val] of Object.entries(__obj)) __out[__k] = __decodeArg(__val);`,
1987
+ ` return __out;`,
1988
+ ` }`,
1989
+ ` return __v;`,
1990
+ `}`,
1712
1991
  ``,
1713
1992
  ];
1714
1993
  if (workerContextMethods.length > 0) {
@@ -1728,29 +2007,22 @@ function generateHandlerProxy(handler, projectDir) {
1728
2007
  lines.push(` if (typeof __local === 'function' && !__asyncMethods.has(__k)) {`);
1729
2008
  lines.push(` return (...__a) => __local.apply(__self, __a);`);
1730
2009
  lines.push(` }`);
1731
- lines.push(` return async (...__a) => { const __s = await __getDoStub('${handler.binding}'); if (!__s) throw new Error('Not authenticated'); return __s[__k](...__a); };`);
2010
+ lines.push(` return async (...__a) => { const __s = await __getDoStub('${handler.binding}'); if (!__s) throw new Error('Not authenticated'); return __s[__k](...__a.map((__x) => __encodeArg(__x))); };`);
1732
2011
  lines.push(` },`);
1733
2012
  lines.push(` });`);
1734
- lines.push(` return ${handlerLocal}.prototype[__name].apply(__self, __args);`);
2013
+ lines.push(` return ${handlerLocal}.prototype[__name].apply(__self, __args.map(__decodeArg));`);
1735
2014
  lines.push(`}`);
1736
2015
  lines.push(``);
1737
2016
  }
1738
- // Export class methods (skip if a custom export has the same name)
1739
- for (const method of publicMethods) {
1740
- if (customSet.has(method))
1741
- continue; // user's export wins
2017
+ // Export RPC methods
2018
+ for (const method of rpcFunctions) {
1742
2019
  if (workerContextMethods.includes(method)) {
1743
2020
  lines.push(`export async function ${method}(...a) { return __callWorkerMethod('${method}', a); }`);
1744
2021
  }
1745
2022
  else {
1746
- lines.push(`export async function ${method}(...a) { const s = await __getDoStub('${handler.binding}'); if (!s) throw new Error('Not authenticated'); return s.${method}(...a); }`);
2023
+ lines.push(`export async function ${method}(...a) { const s = await __getDoStub('${handler.binding}'); if (!s) throw new Error('Not authenticated'); return s.${method}(...a.map((__x) => __encodeArg(__x))); }`);
1747
2024
  }
1748
2025
  }
1749
- // Re-export custom named exports from the original file
1750
- if (handler.namedExports.length > 0) {
1751
- lines.push(``);
1752
- lines.push(`export { ${handler.namedExports.join(', ')} } from '${origRelPath}';`);
1753
- }
1754
2026
  return lines.join('\n') + '\n';
1755
2027
  }
1756
2028
  function generateRoutesModule(opts) {
@@ -1763,16 +2035,16 @@ function generateRoutesModule(opts) {
1763
2035
  .map(([status, fn]) => fn)
1764
2036
  .join('\n\n');
1765
2037
  // Resolve path to the framework's context module from the output directory
1766
- const contextImport = `import { __setRequestContext, __esc, __setLocal, __getLocals, buildDefaultBreadcrumbs as __buildDefaultBreadcrumbs } from '${RUNTIME_CONTEXT_IMPORT}';`;
2038
+ const contextImport = `import { __setRequestContext, __esc, __rawHtml, __sanitizeHtml, __setLocal, __getLocals, buildDefaultBreadcrumbs as __buildDefaultBreadcrumbs } from '${RUNTIME_CONTEXT_IMPORT}';`;
1767
2039
  const runtimeImport = opts.hasRuntime && opts.runtimeImportPath
1768
2040
  ? `import __kuratchiRuntime from '${opts.runtimeImportPath}';`
1769
2041
  : '';
1770
- // Auth session init — thin cookie parsing injected into Worker entry
2042
+ // Auth session init â€" thin cookie parsing injected into Worker entry
1771
2043
  let authInit = '';
1772
2044
  if (opts.authConfig && opts.authConfig.sessionEnabled) {
1773
2045
  const cookieName = opts.authConfig.cookieName;
1774
2046
  authInit = `
1775
- // ── Auth Session Init ───────────────────────────────────────
2047
+ // â"€â"€ Auth Session Init â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
1776
2048
 
1777
2049
  function __parseCookies(header) {
1778
2050
  const map = {};
@@ -1823,7 +2095,7 @@ function __initAuth(request) {
1823
2095
  ...schemaImports,
1824
2096
  ].join('\n');
1825
2097
  migrationInit = `
1826
- // ── ORM Auto-Migration ──────────────────────────────────────
2098
+ // â"€â"€ ORM Auto-Migration â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
1827
2099
 
1828
2100
  let __migrated = false;
1829
2101
  const __ormDatabases = [
@@ -1857,7 +2129,7 @@ async function __runMigrations() {
1857
2129
  `;
1858
2130
  }
1859
2131
  }
1860
- // Auth plugin init — import config + call @kuratchi/auth setup functions
2132
+ // Auth plugin init â€" import config + call @kuratchi/auth setup functions
1861
2133
  let authPluginImports = '';
1862
2134
  let authPluginInit = '';
1863
2135
  const ac = opts.authConfig;
@@ -1909,14 +2181,14 @@ async function __runMigrations() {
1909
2181
  }
1910
2182
  authPluginImports = imports.join('\n');
1911
2183
  authPluginInit = `
1912
- // ── Auth Plugin Init ───────────────────────────────────────
2184
+ // â"€â"€ Auth Plugin Init â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
1913
2185
 
1914
2186
  function __initAuthPlugins() {
1915
2187
  ${initLines.join('\n')}
1916
2188
  }
1917
2189
  `;
1918
2190
  }
1919
- // ── Durable Object class generation ───────────────────────────
2191
+ // â"€â"€ Durable Object class generation â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
1920
2192
  let doImports = '';
1921
2193
  let doClassCode = '';
1922
2194
  let doResolverInit = '';
@@ -1926,7 +2198,28 @@ ${initLines.join('\n')}
1926
2198
  const doResolverLines = [];
1927
2199
  doImportLines.push(`import { DurableObject as __DO } from 'cloudflare:workers';`);
1928
2200
  doImportLines.push(`import { initDO as __initDO } from '@kuratchi/orm';`);
1929
- doImportLines.push(`import { __registerDoResolver, __registerDoClassBinding } from '${RUNTIME_DO_IMPORT}';`);
2201
+ doImportLines.push(`import { __registerDoResolver, __registerDoClassBinding, __setDoContext } from '${RUNTIME_DO_IMPORT}';`);
2202
+ doImportLines.push(`const __DO_FD_TAG = '__kuratchi_form_data__';`);
2203
+ doImportLines.push(`function __isDoPlainObject(__v) {`);
2204
+ doImportLines.push(` if (!__v || typeof __v !== 'object') return false;`);
2205
+ doImportLines.push(` const __proto = Object.getPrototypeOf(__v);`);
2206
+ doImportLines.push(` return __proto === Object.prototype || __proto === null;`);
2207
+ doImportLines.push(`}`);
2208
+ doImportLines.push(`function __decodeDoArg(__v) {`);
2209
+ doImportLines.push(` if (Array.isArray(__v)) return __v.map(__decodeDoArg);`);
2210
+ doImportLines.push(` if (__isDoPlainObject(__v)) {`);
2211
+ doImportLines.push(` if (__DO_FD_TAG in __v) {`);
2212
+ doImportLines.push(` const __fd = new FormData();`);
2213
+ doImportLines.push(` const __entries = Array.isArray(__v[__DO_FD_TAG]) ? __v[__DO_FD_TAG] : [];`);
2214
+ doImportLines.push(` for (const __pair of __entries) { if (Array.isArray(__pair) && __pair.length >= 2) __fd.append(String(__pair[0]), __pair[1]); }`);
2215
+ doImportLines.push(` return __fd;`);
2216
+ doImportLines.push(` }`);
2217
+ doImportLines.push(` const __out = {};`);
2218
+ doImportLines.push(` for (const [__k, __val] of Object.entries(__v)) __out[__k] = __decodeDoArg(__val);`);
2219
+ doImportLines.push(` return __out;`);
2220
+ doImportLines.push(` }`);
2221
+ doImportLines.push(` return __v;`);
2222
+ doImportLines.push(`}`);
1930
2223
  // We need getCurrentUser and getOrgStubByName for stub resolvers
1931
2224
  doImportLines.push(`import { getCurrentUser as __getCU, getOrgStubByName as __getOSBN } from '@kuratchi/auth';`);
1932
2225
  // Group handlers by binding
@@ -1940,6 +2233,10 @@ ${initLines.join('\n')}
1940
2233
  for (const doEntry of opts.doConfig) {
1941
2234
  const handlers = handlersByBinding.get(doEntry.binding) ?? [];
1942
2235
  const ormDb = opts.ormDatabases.find(d => d.binding === doEntry.binding);
2236
+ const fnHandlers = handlers.filter((h) => h.mode === 'function');
2237
+ const initHandlers = fnHandlers.filter((h) => h.exportedFunctions.includes('onInit'));
2238
+ const alarmHandlers = fnHandlers.filter((h) => h.exportedFunctions.includes('onAlarm'));
2239
+ const messageHandlers = fnHandlers.filter((h) => h.exportedFunctions.includes('onMessage'));
1943
2240
  // Import schema (paths are relative to project root; prefix ../ since we're in .kuratchi/)
1944
2241
  if (ormDb) {
1945
2242
  const schemaPath = ormDb.schemaImportPath.replace(/^\.\//, '../');
@@ -1954,7 +2251,12 @@ ${initLines.join('\n')}
1954
2251
  if (!handlerImportPath.startsWith('.'))
1955
2252
  handlerImportPath = './' + handlerImportPath;
1956
2253
  const handlerVar = `__handler_${toSafeIdentifier(h.fileName)}`;
1957
- doImportLines.push(`import ${handlerVar} from '${handlerImportPath}';`);
2254
+ if (h.mode === 'class') {
2255
+ doImportLines.push(`import ${handlerVar} from '${handlerImportPath}';`);
2256
+ }
2257
+ else {
2258
+ doImportLines.push(`import * as ${handlerVar} from '${handlerImportPath}';`);
2259
+ }
1958
2260
  }
1959
2261
  // Generate DO class
1960
2262
  doClassLines.push(`export class ${doEntry.className} extends __DO {`);
@@ -1963,6 +2265,11 @@ ${initLines.join('\n')}
1963
2265
  if (ormDb) {
1964
2266
  doClassLines.push(` this.db = __initDO(ctx.storage.sql, __doSchema_${doEntry.binding});`);
1965
2267
  }
2268
+ for (const h of initHandlers) {
2269
+ const handlerVar = `__handler_${toSafeIdentifier(h.fileName)}`;
2270
+ doClassLines.push(` __setDoContext(this);`);
2271
+ doClassLines.push(` Promise.resolve(${handlerVar}.onInit.call(this)).catch((err) => console.error('[kuratchi] DO onInit failed:', err?.message || err));`);
2272
+ }
1966
2273
  doClassLines.push(` }`);
1967
2274
  if (ormDb) {
1968
2275
  doClassLines.push(` async __kuratchiLogActivity(payload) {`);
@@ -2001,16 +2308,45 @@ ${initLines.join('\n')}
2001
2308
  doClassLines.push(` return rows;`);
2002
2309
  doClassLines.push(` }`);
2003
2310
  }
2311
+ // Function-mode lifecycle dispatchers
2312
+ if (alarmHandlers.length > 0) {
2313
+ doClassLines.push(` async alarm(...args) {`);
2314
+ doClassLines.push(` __setDoContext(this);`);
2315
+ for (const h of alarmHandlers) {
2316
+ const handlerVar = `__handler_${toSafeIdentifier(h.fileName)}`;
2317
+ doClassLines.push(` await ${handlerVar}.onAlarm.call(this, ...args);`);
2318
+ }
2319
+ doClassLines.push(` }`);
2320
+ }
2321
+ if (messageHandlers.length > 0) {
2322
+ doClassLines.push(` webSocketMessage(...args) {`);
2323
+ doClassLines.push(` __setDoContext(this);`);
2324
+ for (const h of messageHandlers) {
2325
+ const handlerVar = `__handler_${toSafeIdentifier(h.fileName)}`;
2326
+ doClassLines.push(` ${handlerVar}.onMessage.call(this, ...args);`);
2327
+ }
2328
+ doClassLines.push(` }`);
2329
+ }
2004
2330
  doClassLines.push(`}`);
2005
- // Apply handler methods to prototype
2331
+ // Apply handler methods to prototype (outside class body)
2006
2332
  for (const h of handlers) {
2007
2333
  const handlerVar = `__handler_${toSafeIdentifier(h.fileName)}`;
2008
- doClassLines.push(`for (const __k of Object.getOwnPropertyNames(${handlerVar}.prototype)) { if (__k !== 'constructor') ${doEntry.className}.prototype[__k] = ${handlerVar}.prototype[__k]; }`);
2009
- doResolverLines.push(` __registerDoClassBinding(${handlerVar}, '${doEntry.binding}');`);
2334
+ if (h.mode === 'class') {
2335
+ doClassLines.push(`for (const __k of Object.getOwnPropertyNames(${handlerVar}.prototype)) { if (__k !== 'constructor') ${doEntry.className}.prototype[__k] = function(...__a){ __setDoContext(this); return ${handlerVar}.prototype[__k].apply(this, __a.map(__decodeDoArg)); }; }`);
2336
+ doResolverLines.push(` __registerDoClassBinding(${handlerVar}, '${doEntry.binding}');`);
2337
+ }
2338
+ else {
2339
+ const lifecycle = new Set(['onInit', 'onAlarm', 'onMessage']);
2340
+ for (const fn of h.exportedFunctions) {
2341
+ if (lifecycle.has(fn))
2342
+ continue;
2343
+ doClassLines.push(`${doEntry.className}.prototype[${JSON.stringify(fn)}] = function(...__a){ __setDoContext(this); return ${handlerVar}.${fn}.apply(this, __a.map(__decodeDoArg)); };`);
2344
+ }
2345
+ }
2010
2346
  }
2011
2347
  // Register stub resolver
2012
2348
  if (doEntry.stubId) {
2013
- // Config-driven: e.g. stubId: 'user.orgId' → __u.orgId
2349
+ // Config-driven: e.g. stubId: 'user.orgId' â†' __u.orgId
2014
2350
  const fieldPath = doEntry.stubId.startsWith('user.') ? `__u.${doEntry.stubId.slice(5)}` : doEntry.stubId;
2015
2351
  const checkField = doEntry.stubId.startsWith('user.') ? doEntry.stubId.slice(5) : doEntry.stubId;
2016
2352
  doResolverLines.push(` __registerDoResolver('${doEntry.binding}', async () => {`);
@@ -2020,37 +2356,37 @@ ${initLines.join('\n')}
2020
2356
  doResolverLines.push(` });`);
2021
2357
  }
2022
2358
  else {
2023
- // No stubId config — stub must be obtained manually
2024
- doResolverLines.push(` // No 'stubId' config for ${doEntry.binding} — stub must be obtained manually`);
2359
+ // No stubId config â€" stub must be obtained manually
2360
+ doResolverLines.push(` // No 'stubId' config for ${doEntry.binding} â€" stub must be obtained manually`);
2025
2361
  }
2026
2362
  }
2027
2363
  doImports = doImportLines.join('\n');
2028
- doClassCode = `\n// ── Durable Object Classes (generated) ──────────────────────────\n\n` + doClassLines.join('\n') + '\n';
2364
+ doClassCode = `\n// â"€â"€ Durable Object Classes (generated) â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€\n\n` + doClassLines.join('\n') + '\n';
2029
2365
  doResolverInit = `\nfunction __initDoResolvers() {\n${doResolverLines.join('\n')}\n}\n`;
2030
2366
  }
2031
- return `// Generated by KuratchiJS compiler — do not edit.
2367
+ return `// Generated by KuratchiJS compiler â€" do not edit.
2032
2368
  ${opts.isDev ? '\nglobalThis.__kuratchi_DEV__ = true;\n' : ''}
2033
2369
  ${workerImport}
2034
2370
  ${contextImport}
2035
- ${runtimeImport ? runtimeImport + '\n' : ''}${migrationImports ? migrationImports + '\n' : ''}${authPluginImports ? authPluginImports + '\n' : ''}${doImports ? doImports + '\n' : ''}${opts.serverImports.join('\n')}
2371
+ ${runtimeImport ? runtimeImport + '\n' : ''}${migrationImports ? migrationImports + '\n' : ''}${authPluginImports ? authPluginImports + '\n' : ''}${doImports ? doImports + '\n' : ''}${opts.serverImports.join('\n')}
2036
2372
 
2037
- // ── Assets ──────────────────────────────────────────────────────
2373
+ // â"€â"€ Assets â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
2038
2374
 
2039
2375
  const __assets = {
2040
2376
  ${opts.compiledAssets.map(a => ` ${JSON.stringify(a.name)}: { content: ${JSON.stringify(a.content)}, mime: ${JSON.stringify(a.mime)}, etag: ${JSON.stringify(a.etag)} }`).join(',\n')}
2041
2377
  };
2042
2378
 
2043
- // ── Router ──────────────────────────────────────────────────────
2379
+ // â"€â"€ Router â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
2044
2380
 
2045
- const __staticRoutes = new Map(); // exact path → index (O(1) lookup)
2381
+ const __staticRoutes = new Map(); // exact path â†' index (O(1) lookup)
2046
2382
  const __dynamicRoutes = []; // regex-based routes (params/wildcards)
2047
2383
 
2048
2384
  function __addRoute(pattern, index) {
2049
2385
  if (!pattern.includes(':') && !pattern.includes('*')) {
2050
- // Static route — direct Map lookup, no regex needed
2386
+ // Static route â€" direct Map lookup, no regex needed
2051
2387
  __staticRoutes.set(pattern, index);
2052
2388
  } else {
2053
- // Dynamic route — build regex for param extraction
2389
+ // Dynamic route â€" build regex for param extraction
2054
2390
  const paramNames = [];
2055
2391
  let regexStr = pattern
2056
2392
  .replace(/\\*(\\w+)/g, (_, name) => { paramNames.push(name); return '(?<' + name + '>.+)'; })
@@ -2076,13 +2412,13 @@ function __match(pathname) {
2076
2412
  return null;
2077
2413
  }
2078
2414
 
2079
- // ── Layout ──────────────────────────────────────────────────────
2415
+ // â"€â"€ Layout â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
2080
2416
 
2081
2417
  ${layoutBlock}
2082
2418
 
2083
2419
  ${layoutActionsBlock}
2084
2420
 
2085
- // ── Error pages ─────────────────────────────────────────────────
2421
+ // â"€â"€ Error pages â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
2086
2422
 
2087
2423
  const __errorMessages = {
2088
2424
  400: 'Bad Request',
@@ -2097,7 +2433,7 @@ const __errorMessages = {
2097
2433
  503: 'Service Unavailable',
2098
2434
  };
2099
2435
 
2100
- // Built-in default error page — clean, dark, minimal, centered
2436
+ // Built-in default error page â€" clean, dark, minimal, centered
2101
2437
  function __errorPage(status, detail) {
2102
2438
  const title = __errorMessages[status] || 'Error';
2103
2439
  const detailHtml = detail ? '<p style="font-family:ui-monospace,monospace;font-size:0.8rem;color:#555;background:#111;padding:0.5rem 1rem;border-radius:6px;max-width:480px;margin:1rem auto 0;word-break:break-word">' + __esc(detail) + '</p>' : '';
@@ -2119,8 +2455,8 @@ function __error(status, detail) {
2119
2455
  return __errorPage(status, detail);
2120
2456
  }
2121
2457
 
2122
- ${opts.compiledComponents.length > 0 ? '// ── Components ──────────────────────────────────────────────────\n\n' + opts.compiledComponents.join('\n\n') + '\n' : ''}${migrationInit}${authInit}${authPluginInit}${doResolverInit}${doClassCode}
2123
- // ── Route definitions ───────────────────────────────────────────
2458
+ ${opts.compiledComponents.length > 0 ? '// â"€â"€ Components â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€\n\n' + opts.compiledComponents.join('\n\n') + '\n' : ''}${migrationInit}${authInit}${authPluginInit}${doResolverInit}${doClassCode}
2459
+ // â"€â"€ Route definitions â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
2124
2460
 
2125
2461
  const routes = [
2126
2462
  ${opts.compiledRoutes.join(',\n')}
@@ -2128,7 +2464,7 @@ ${opts.compiledRoutes.join(',\n')}
2128
2464
 
2129
2465
  for (let i = 0; i < routes.length; i++) __addRoute(routes[i].pattern, i);
2130
2466
 
2131
- // ── Response helpers ────────────────────────────────────────────
2467
+ // â"€â"€ Response helpers â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
2132
2468
 
2133
2469
  const __defaultSecHeaders = {
2134
2470
  'X-Content-Type-Options': 'nosniff',
@@ -2232,13 +2568,13 @@ async function __runRuntimeError(ctx, error) {
2232
2568
  return null;
2233
2569
  }
2234
2570
 
2235
- // ── Exported Worker entrypoint ──────────────────────────────────
2571
+ // â"€â"€ Exported Worker entrypoint â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
2236
2572
 
2237
- export default class extends WorkerEntrypoint {
2238
- async fetch(request) {
2239
- __setRequestContext(this.ctx, request);
2240
- globalThis.__cloudflare_env__ = __env;
2241
- ${migrationInit ? ' await __runMigrations();\n' : ''}${authInit ? ' __initAuth(request);\n' : ''}${authPluginInit ? ' __initAuthPlugins();\n' : ''}${doResolverInit ? ' __initDoResolvers();\n' : ''}
2573
+ export default class extends WorkerEntrypoint {
2574
+ async fetch(request) {
2575
+ __setRequestContext(this.ctx, request);
2576
+ globalThis.__cloudflare_env__ = __env;
2577
+ ${migrationInit ? ' await __runMigrations();\n' : ''}${authInit ? ' __initAuth(request);\n' : ''}${authPluginInit ? ' __initAuthPlugins();\n' : ''}${doResolverInit ? ' __initDoResolvers();\n' : ''}
2242
2578
  const __runtimeCtx = {
2243
2579
  request,
2244
2580
  env: __env,
@@ -2277,6 +2613,24 @@ ${ac?.hasRateLimit ? '\n // Rate limiting - check before route handlers\n
2277
2613
  __runtimeCtx.params = match.params;
2278
2614
  const route = routes[match.index];
2279
2615
  __setLocal('params', match.params);
2616
+
2617
+ // API route: dispatch to method handler
2618
+ if (route.__api) {
2619
+ const method = request.method;
2620
+ if (method === 'OPTIONS') {
2621
+ const handler = route['OPTIONS'];
2622
+ if (typeof handler === 'function') return __secHeaders(await handler(__runtimeCtx));
2623
+ const allowed = ['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS'].filter(m => typeof route[m] === 'function').join(', ');
2624
+ return __secHeaders(new Response(null, { status: 204, headers: { 'Allow': allowed, 'Access-Control-Allow-Methods': allowed } }));
2625
+ }
2626
+ const handler = route[method];
2627
+ if (typeof handler !== 'function') {
2628
+ const allowed = ['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS'].filter(m => typeof route[m] === 'function').join(', ');
2629
+ return __secHeaders(new Response(JSON.stringify({ error: 'Method Not Allowed' }), { status: 405, headers: { 'content-type': 'application/json', 'Allow': allowed } }));
2630
+ }
2631
+ return __secHeaders(await handler(__runtimeCtx));
2632
+ }
2633
+
2280
2634
  const __qFn = request.headers.get('x-kuratchi-query-fn') || '';
2281
2635
  const __qArgsRaw = request.headers.get('x-kuratchi-query-args') || '[]';
2282
2636
  let __qArgs = [];
@@ -2350,7 +2704,10 @@ ${ac?.hasRateLimit ? '\n // Rate limiting - check before route handlers\n
2350
2704
  const data = (__loaded && typeof __loaded === 'object') ? __loaded : { value: __loaded };
2351
2705
  data.params = match.params;
2352
2706
  data.breadcrumbs = __getLocals().__breadcrumbs ?? [];
2353
- data.__error = (typeof __kuratchi_DEV__ !== 'undefined' && err && err.message) ? err.message : 'Action failed';
2707
+ const __allActions = Object.assign({}, route.actions, __layoutActions || {});
2708
+ Object.keys(__allActions).forEach(function(k) { if (!(k in data)) data[k] = { error: undefined, loading: false, success: false }; });
2709
+ const __errMsg = (err && err.isActionError) ? err.message : (typeof __kuratchi_DEV__ !== 'undefined' && err && err.message) ? err.message : 'Action failed';
2710
+ data[actionName] = { error: __errMsg, loading: false, success: false };
2354
2711
  return ${opts.isLayoutAsync ? 'await ' : ''}__render(route, data);
2355
2712
  }
2356
2713
  // Fetch-based actions return lightweight JSON (no page re-render)
@@ -2373,11 +2730,14 @@ ${ac?.hasRateLimit ? '\n // Rate limiting - check before route handlers\n
2373
2730
  const data = (__loaded && typeof __loaded === 'object') ? __loaded : { value: __loaded };
2374
2731
  data.params = match.params;
2375
2732
  data.breadcrumbs = __getLocals().__breadcrumbs ?? [];
2733
+ const __allActionsGet = Object.assign({}, route.actions, __layoutActions || {});
2734
+ Object.keys(__allActionsGet).forEach(function(k) { if (!(k in data)) data[k] = { error: undefined, loading: false, success: false }; });
2376
2735
  return ${opts.isLayoutAsync ? 'await ' : ''}__render(route, data);
2377
2736
  } catch (err) {
2378
2737
  console.error('[kuratchi] Route load/render error:', err);
2379
- const __errDetail = typeof __kuratchi_DEV__ !== 'undefined' ? err.message : undefined;
2380
- return __secHeaders(new Response(${opts.isLayoutAsync ? 'await ' : ''}__layout(__error(500, __errDetail)), { status: 500, headers: { 'content-type': 'text/html; charset=utf-8' } }));
2738
+ const __pageErrStatus = (err && err.isPageError && err.status) ? err.status : 500;
2739
+ const __errDetail = (err && err.isPageError) ? err.message : (typeof __kuratchi_DEV__ !== 'undefined' && err && err.message) ? err.message : undefined;
2740
+ return __secHeaders(new Response(${opts.isLayoutAsync ? 'await ' : ''}__layout(__error(__pageErrStatus, __errDetail)), { status: __pageErrStatus, headers: { 'content-type': 'text/html; charset=utf-8' } }));
2381
2741
  }
2382
2742
  };
2383
2743