@kuratchi/js 0.0.3 → 0.0.5

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,9 +1,10 @@
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';
6
6
  import { compileTemplate } from './template.js';
7
+ import { transpileTypeScript } from './transpile.js';
7
8
  import { filePathToPattern } from '../runtime/router.js';
8
9
  import * as fs from 'node:fs';
9
10
  import * as path from 'node:path';
@@ -31,10 +32,56 @@ function compactInlineJs(source) {
31
32
  .replace(/\s*([{}();,:])\s*/g, '$1')
32
33
  .trim();
33
34
  }
35
+ function rewriteImportedFunctionCalls(source, fnToModule) {
36
+ let out = source;
37
+ for (const [fnName, moduleId] of Object.entries(fnToModule)) {
38
+ if (!/^[A-Za-z_$][\w$]*$/.test(fnName))
39
+ continue;
40
+ const callRegex = new RegExp(`\\b${fnName}\\s*\\(`, 'g');
41
+ out = out.replace(callRegex, `${moduleId}.${fnName}(`);
42
+ }
43
+ return out;
44
+ }
45
+ function rewriteWorkerEnvAliases(source, aliases) {
46
+ let out = source;
47
+ for (const alias of aliases) {
48
+ if (!/^[A-Za-z_$][\w$]*$/.test(alias))
49
+ continue;
50
+ const aliasRegex = new RegExp(`\\b${alias}\\b`, 'g');
51
+ out = out.replace(aliasRegex, '__env');
52
+ }
53
+ return out;
54
+ }
55
+ function parseNamedImportBindings(line) {
56
+ const namesMatch = line.match(/import\s*\{([^}]+)\}/);
57
+ if (!namesMatch)
58
+ return [];
59
+ return namesMatch[1]
60
+ .split(',')
61
+ .map(n => n.trim())
62
+ .filter(Boolean)
63
+ .map(n => {
64
+ const parts = n.split(/\s+as\s+/).map(p => p.trim()).filter(Boolean);
65
+ return parts[1] || parts[0] || '';
66
+ })
67
+ .filter(Boolean);
68
+ }
69
+ function filterClientImportsForServer(imports, neededFns) {
70
+ const selected = [];
71
+ for (const line of imports) {
72
+ const bindings = parseNamedImportBindings(line);
73
+ if (bindings.length === 0)
74
+ continue;
75
+ if (bindings.some(name => neededFns.has(name))) {
76
+ selected.push(line);
77
+ }
78
+ }
79
+ return selected;
80
+ }
34
81
  /**
35
82
  * Compile a project's src/routes/ into .kuratchi/routes.js
36
83
  *
37
- * The generated module exports { app } — an object with a fetch() method
84
+ * The generated module exports { app } â€" an object with a fetch() method
38
85
  * that handles routing, load functions, form actions, and rendering.
39
86
  * Returns the path to .kuratchi/worker.js — the stable wrangler entry point that
40
87
  * re-exports everything from routes.js (default fetch handler + named DO class exports).
@@ -49,21 +96,21 @@ export function compile(options) {
49
96
  }
50
97
  // Discover all .html route files
51
98
  const routeFiles = discoverRoutes(routesDir);
52
- // Component compilation cache — only compile components that are actually imported
99
+ // Component compilation cache â€" only compile components that are actually imported
53
100
  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)
101
+ const compiledComponentCache = new Map(); // fileName â†' compiled function code
102
+ const componentStyleCache = new Map(); // fileName â†' escaped CSS string (or empty)
56
103
  // Tracks which prop names inside a component are used as action={propName}.
57
- // e.g. db-studio uses action={runQueryAction} → stores 'runQueryAction'.
104
+ // e.g. db-studio uses action={runQueryAction} â†' stores 'runQueryAction'.
58
105
  // When the route passes runQueryAction={runAdminSqlQuery}, the compiler knows
59
106
  // to add 'runAdminSqlQuery' to the route's actionFunctions.
60
- const componentActionCache = new Map(); // fileName → Set of action prop names
107
+ const componentActionCache = new Map(); // fileName â†' Set of action prop names
61
108
  function compileComponent(fileName) {
62
109
  if (compiledComponentCache.has(fileName))
63
110
  return compiledComponentCache.get(fileName);
64
111
  let filePath;
65
112
  let funcName;
66
- // Package component: "@kuratchi/ui:badge" → resolve from package
113
+ // Package component: "@kuratchi/ui:badge" â†' resolve from package
67
114
  const pkgMatch = fileName.match(/^(@[^:]+):(.+)$/);
68
115
  if (pkgMatch) {
69
116
  const pkgName = pkgMatch[1]; // e.g. "@kuratchi/ui"
@@ -87,16 +134,19 @@ export function compile(options) {
87
134
  // Use parseFile to properly split the <script> block from the template, and to
88
135
  // separate component imports (import X from '@kuratchi/ui/x.html') from regular code.
89
136
  // This prevents import lines from being inlined verbatim in the compiled function body.
90
- const compParsed = parseFile(rawSource);
137
+ const compParsed = parseFile(rawSource, { kind: 'component', filePath });
91
138
  // propsCode = script body with all import lines stripped out
92
139
  const propsCode = compParsed.script
93
140
  ? compParsed.script
94
141
  .replace(/^\s*import[\s\S]*?from\s+['"][^'"]+['"]\s*;?/gm, '')
95
142
  .trim()
96
143
  : '';
144
+ const transpiledPropsCode = propsCode
145
+ ? transpileTypeScript(propsCode, `component-script:${fileName}.ts`)
146
+ : '';
97
147
  // template source (parseFile already removes the <script> block)
98
148
  let source = compParsed.template;
99
- // Extract optional <style> block — CSS is scoped and injected once per route at compile time
149
+ // Extract optional <style> block â€" CSS is scoped and injected once per route at compile time
100
150
  let styleBlock = '';
101
151
  const styleMatch = source.match(/<style[\s>][\s\S]*?<\/style>/i);
102
152
  if (styleMatch) {
@@ -124,8 +174,8 @@ export function compile(options) {
124
174
  : '';
125
175
  componentStyleCache.set(fileName, escapedStyle);
126
176
  // 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 || ""}');
177
+ source = source.replace(/<slot\s*><\/slot>/g, '{@raw props.children || ""}');
178
+ source = source.replace(/<slot\s*\/>/g, '{@raw props.children || ""}');
129
179
  // Build a sub-component map from the component's own component imports so that
130
180
  // <Alert>, <Badge>, <Dialog>, etc. get expanded instead of emitted as raw tags.
131
181
  const subComponentNames = new Map();
@@ -142,7 +192,7 @@ export function compile(options) {
142
192
  }
143
193
  }
144
194
  // Scan the component template for action={propName} uses.
145
- // These prop names are "action props" — when the route passes actionProp={routeFn},
195
+ // These prop names are "action props" â€" when the route passes actionProp={routeFn},
146
196
  // the compiler knows to add routeFn to the route's actionFunctions so it ends up
147
197
  // in the route's actions map and can be dispatched at runtime.
148
198
  const actionPropNames = new Set();
@@ -157,12 +207,12 @@ export function compile(options) {
157
207
  // Insert scope open after 'let __html = "";' (first line of body) and scope close at end
158
208
  const bodyLines = body.split('\n');
159
209
  const scopedBody = [bodyLines[0], scopeOpen, ...bodyLines.slice(1), scopeClose].join('\n');
160
- const fnBody = propsCode ? `${propsCode}\n ${scopedBody}` : scopedBody;
210
+ const fnBody = transpiledPropsCode ? `${transpiledPropsCode}\n ${scopedBody}` : scopedBody;
161
211
  const compiled = `function ${funcName}(props, __esc) {\n ${fnBody}\n return __html;\n}`;
162
212
  compiledComponentCache.set(fileName, compiled);
163
213
  return compiled;
164
214
  }
165
- // App layout: src/routes/layout.html (convention — wraps all routes automatically)
215
+ // App layout: src/routes/layout.html (convention â€" wraps all routes automatically)
166
216
  const layoutFile = path.join(routesDir, 'layout.html');
167
217
  let compiledLayout = null;
168
218
  const layoutComponentNames = new Map();
@@ -444,6 +494,17 @@ export function compile(options) {
444
494
  if(!msg) return;
445
495
  if(!window.confirm(msg)){ e.preventDefault(); e.stopPropagation(); }
446
496
  }, true);
497
+ document.addEventListener('submit', function(e){
498
+ if(e.defaultPrevented) return;
499
+ var f = e.target;
500
+ if(!f || !f.querySelector) return;
501
+ var aInput = f.querySelector('input[name="_action"]');
502
+ if(!aInput) return;
503
+ var aName = aInput.value;
504
+ if(!aName) return;
505
+ f.setAttribute('data-action-loading', aName);
506
+ Array.prototype.slice.call(f.querySelectorAll('button[type="submit"],button:not([type="button"]):not([type="reset"])')).forEach(function(b){ b.disabled = true; });
507
+ }, true);
447
508
  document.addEventListener('change', function(e){
448
509
  var t = e.target;
449
510
  if(!t || !t.getAttribute) return;
@@ -458,21 +519,118 @@ export function compile(options) {
458
519
  }, true);
459
520
  by('[data-select-all]').forEach(function(m){ var g = m.getAttribute('data-select-all'); if(g) syncGroup(g); });
460
521
  })();`;
522
+ const reactiveRuntimeSource = `(function(g){
523
+ if(g.__kuratchiReactive) return;
524
+ const targetMap = new WeakMap();
525
+ const proxyMap = new WeakMap();
526
+ let active = null;
527
+ const queue = new Set();
528
+ let flushing = false;
529
+ function queueRun(fn){
530
+ queue.add(fn);
531
+ if(flushing) return;
532
+ flushing = true;
533
+ Promise.resolve().then(function(){
534
+ try {
535
+ const jobs = Array.from(queue);
536
+ queue.clear();
537
+ for (const job of jobs) job();
538
+ } finally {
539
+ flushing = false;
540
+ }
541
+ });
542
+ }
543
+ function cleanup(effect){
544
+ const deps = effect.__deps || [];
545
+ for (const dep of deps) dep.delete(effect);
546
+ effect.__deps = [];
547
+ }
548
+ function track(target, key){
549
+ if(!active) return;
550
+ let depsMap = targetMap.get(target);
551
+ if(!depsMap){ depsMap = new Map(); targetMap.set(target, depsMap); }
552
+ let dep = depsMap.get(key);
553
+ if(!dep){ dep = new Set(); depsMap.set(key, dep); }
554
+ if(dep.has(active)) return;
555
+ dep.add(active);
556
+ if(!active.__deps) active.__deps = [];
557
+ active.__deps.push(dep);
558
+ }
559
+ function trigger(target, key){
560
+ const depsMap = targetMap.get(target);
561
+ if(!depsMap) return;
562
+ const effects = new Set();
563
+ const add = function(k){
564
+ const dep = depsMap.get(k);
565
+ if(dep) dep.forEach(function(e){ effects.add(e); });
566
+ };
567
+ add(key);
568
+ add('*');
569
+ effects.forEach(function(e){ queueRun(e); });
570
+ }
571
+ function isObject(value){ return value !== null && typeof value === 'object'; }
572
+ function proxify(value){
573
+ if(!isObject(value)) return value;
574
+ if(proxyMap.has(value)) return proxyMap.get(value);
575
+ const proxy = new Proxy(value, {
576
+ get(target, key, receiver){
577
+ track(target, key);
578
+ const out = Reflect.get(target, key, receiver);
579
+ return isObject(out) ? proxify(out) : out;
580
+ },
581
+ set(target, key, next, receiver){
582
+ const prev = target[key];
583
+ const result = Reflect.set(target, key, next, receiver);
584
+ if(prev !== next) trigger(target, key);
585
+ if(Array.isArray(target) && key !== 'length') trigger(target, 'length');
586
+ return result;
587
+ },
588
+ deleteProperty(target, key){
589
+ const had = Object.prototype.hasOwnProperty.call(target, key);
590
+ const result = Reflect.deleteProperty(target, key);
591
+ if(had) trigger(target, key);
592
+ return result;
593
+ }
594
+ });
595
+ proxyMap.set(value, proxy);
596
+ return proxy;
597
+ }
598
+ function effect(fn){
599
+ const run = function(){
600
+ cleanup(run);
601
+ active = run;
602
+ try { fn(); } finally { active = null; }
603
+ };
604
+ run.__deps = [];
605
+ run();
606
+ return function(){ cleanup(run); };
607
+ }
608
+ function state(initial){ return proxify(initial); }
609
+ function replace(_prev, next){ return proxify(next); }
610
+ g.__kuratchiReactive = { state, effect, replace };
611
+ })(window);`;
461
612
  const actionScript = `<script>${options.isDev ? bridgeSource : compactInlineJs(bridgeSource)}</script>`;
613
+ const reactiveRuntimeScript = `<script>${options.isDev ? reactiveRuntimeSource : compactInlineJs(reactiveRuntimeSource)}</script>`;
614
+ if (source.includes('</head>')) {
615
+ source = source.replace('</head>', reactiveRuntimeScript + '\n</head>');
616
+ }
617
+ else {
618
+ source = reactiveRuntimeScript + '\n' + source;
619
+ }
462
620
  source = source.replace('</body>', actionScript + '\n</body>');
463
621
  // Parse layout for <script> block (component imports + data vars)
464
- const layoutParsed = parseFile(source);
622
+ const layoutParsed = parseFile(source, { kind: 'layout', filePath: layoutFile });
465
623
  const hasLayoutScript = layoutParsed.script && (Object.keys(layoutParsed.componentImports).length > 0 || layoutParsed.hasLoad);
466
624
  if (hasLayoutScript) {
467
- // Dynamic layout — has component imports and/or data declarations
625
+ // Dynamic layout â€" has component imports and/or data declarations
468
626
  // Compile component imports from layout
469
627
  for (const [pascalName, fileName] of Object.entries(layoutParsed.componentImports)) {
470
628
  compileComponent(fileName);
471
629
  layoutComponentNames.set(pascalName, fileName);
472
630
  }
473
631
  // 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}');
632
+ let layoutTemplate = layoutParsed.template.replace(/<slot\s*><\/slot>/g, '{@raw __content}');
633
+ layoutTemplate = layoutTemplate.replace(/<slot\s*\/>/g, '{@raw __content}');
476
634
  // Build layout action names so action={fn} works in layouts
477
635
  const layoutActionNames = new Set(layoutParsed.actionFunctions);
478
636
  // Compile the layout template with component + action support
@@ -500,7 +658,7 @@ export function compile(options) {
500
658
  }`;
501
659
  }
502
660
  else {
503
- // Static layout — no components, use fast string split (original behavior)
661
+ // Static layout â€" no components, use fast string split (original behavior)
504
662
  const slotMarker = '<slot></slot>';
505
663
  const slotIdx = source.indexOf(slotMarker);
506
664
  if (slotIdx === -1) {
@@ -513,7 +671,7 @@ export function compile(options) {
513
671
  }
514
672
  }
515
673
  // 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
674
+ // Only compiled if the user explicitly creates them â€" otherwise the framework's built-in default is used
517
675
  const compiledErrorPages = new Map();
518
676
  for (const file of fs.readdirSync(routesDir)) {
519
677
  const match = file.match(/^(\d{3})\.html$/);
@@ -573,16 +731,130 @@ export function compile(options) {
573
731
  }
574
732
  }
575
733
  }
734
+ const resolveExistingModuleFile = (absBase) => {
735
+ const candidates = [
736
+ absBase,
737
+ absBase + '.ts',
738
+ absBase + '.js',
739
+ absBase + '.mjs',
740
+ absBase + '.cjs',
741
+ path.join(absBase, 'index.ts'),
742
+ path.join(absBase, 'index.js'),
743
+ path.join(absBase, 'index.mjs'),
744
+ path.join(absBase, 'index.cjs'),
745
+ ];
746
+ for (const candidate of candidates) {
747
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile())
748
+ return candidate;
749
+ }
750
+ return null;
751
+ };
752
+ const toModuleSpecifier = (fromFileAbs, toFileAbs) => {
753
+ let rel = path.relative(path.dirname(fromFileAbs), toFileAbs).replace(/\\/g, '/');
754
+ if (!rel.startsWith('.'))
755
+ rel = './' + rel;
756
+ return rel;
757
+ };
758
+ const transformedServerModules = new Map();
759
+ const modulesOutDir = path.join(projectDir, '.kuratchi', 'modules');
760
+ const resolveDoProxyTarget = (absPath) => {
761
+ const normalizedNoExt = absPath.replace(/\\/g, '/').replace(/\.[^.\/]+$/, '');
762
+ const proxyNoExt = doHandlerProxyPaths.get(normalizedNoExt);
763
+ if (!proxyNoExt)
764
+ return null;
765
+ return resolveExistingModuleFile(proxyNoExt) ?? (fs.existsSync(proxyNoExt + '.js') ? proxyNoExt + '.js' : null);
766
+ };
767
+ const resolveImportTarget = (importerAbs, spec) => {
768
+ if (spec.startsWith('$')) {
769
+ const slashIdx = spec.indexOf('/');
770
+ const folder = slashIdx === -1 ? spec.slice(1) : spec.slice(1, slashIdx);
771
+ const rest = slashIdx === -1 ? '' : spec.slice(slashIdx + 1);
772
+ if (!folder)
773
+ return null;
774
+ const abs = path.join(srcDir, folder, rest);
775
+ return resolveExistingModuleFile(abs) ?? abs;
776
+ }
777
+ if (spec.startsWith('.')) {
778
+ const abs = path.resolve(path.dirname(importerAbs), spec);
779
+ return resolveExistingModuleFile(abs) ?? abs;
780
+ }
781
+ return null;
782
+ };
783
+ const transformServerModule = (entryAbsPath) => {
784
+ const resolved = resolveExistingModuleFile(entryAbsPath) ?? entryAbsPath;
785
+ const normalized = resolved.replace(/\\/g, '/');
786
+ const cached = transformedServerModules.get(normalized);
787
+ if (cached)
788
+ return cached;
789
+ const relFromProject = path.relative(projectDir, resolved);
790
+ const outPath = path.join(modulesOutDir, relFromProject);
791
+ transformedServerModules.set(normalized, outPath);
792
+ const outDir = path.dirname(outPath);
793
+ if (!fs.existsSync(outDir))
794
+ fs.mkdirSync(outDir, { recursive: true });
795
+ if (!/\.(ts|js|mjs|cjs)$/i.test(resolved) || !fs.existsSync(resolved)) {
796
+ const passthrough = resolved;
797
+ transformedServerModules.set(normalized, passthrough);
798
+ return passthrough;
799
+ }
800
+ const source = fs.readFileSync(resolved, 'utf-8');
801
+ const rewriteSpecifier = (spec) => {
802
+ const target = resolveImportTarget(resolved, spec);
803
+ if (!target)
804
+ return spec;
805
+ const doProxyTarget = resolveDoProxyTarget(target);
806
+ if (doProxyTarget)
807
+ return toModuleSpecifier(outPath, doProxyTarget);
808
+ const normalizedTarget = target.replace(/\\/g, '/');
809
+ const inProject = normalizedTarget.startsWith(projectDir.replace(/\\/g, '/') + '/');
810
+ if (!inProject)
811
+ return spec;
812
+ const targetResolved = resolveExistingModuleFile(target) ?? target;
813
+ if (!/\.(ts|js|mjs|cjs)$/i.test(targetResolved))
814
+ return spec;
815
+ const rewrittenTarget = transformServerModule(targetResolved);
816
+ return toModuleSpecifier(outPath, rewrittenTarget);
817
+ };
818
+ let rewritten = source.replace(/(from\s+)(['"])([^'"]+)\2/g, (_m, p1, q, spec) => {
819
+ return `${p1}${q}${rewriteSpecifier(spec)}${q}`;
820
+ });
821
+ rewritten = rewritten.replace(/(import\s*\(\s*)(['"])([^'"]+)\2(\s*\))/g, (_m, p1, q, spec, p4) => {
822
+ return `${p1}${q}${rewriteSpecifier(spec)}${q}${p4}`;
823
+ });
824
+ writeIfChanged(outPath, rewritten);
825
+ return outPath;
826
+ };
827
+ const resolveCompiledImportPath = (origPath, importerDir, outFileDir) => {
828
+ const isBareModule = !origPath.startsWith('.') && !origPath.startsWith('/') && !origPath.startsWith('$');
829
+ if (isBareModule)
830
+ return origPath;
831
+ let absImport;
832
+ if (origPath.startsWith('$')) {
833
+ const slashIdx = origPath.indexOf('/');
834
+ const folder = slashIdx === -1 ? origPath.slice(1) : origPath.slice(1, slashIdx);
835
+ const rest = slashIdx === -1 ? '' : origPath.slice(slashIdx + 1);
836
+ absImport = path.join(srcDir, folder, rest);
837
+ }
838
+ else {
839
+ absImport = path.resolve(importerDir, origPath);
840
+ }
841
+ const doProxyTarget = resolveDoProxyTarget(absImport);
842
+ const target = doProxyTarget ?? transformServerModule(absImport);
843
+ let relPath = path.relative(outFileDir, target).replace(/\\/g, '/');
844
+ if (!relPath.startsWith('.'))
845
+ relPath = './' + relPath;
846
+ return relPath;
847
+ };
576
848
  // Parse and compile each route
577
849
  const compiledRoutes = [];
578
850
  const allImports = [];
579
851
  let moduleCounter = 0;
580
- // Layout server import resolution — resolve non-component imports to module IDs
852
+ // Layout server import resolution â€" resolve non-component imports to module IDs
581
853
  let isLayoutAsync = false;
582
854
  let compiledLayoutActions = null;
583
855
  if (compiledLayout && fs.existsSync(path.join(routesDir, 'layout.html'))) {
584
856
  const layoutSource = fs.readFileSync(path.join(routesDir, 'layout.html'), 'utf-8');
585
- const layoutParsedForImports = parseFile(layoutSource);
857
+ const layoutParsedForImports = parseFile(layoutSource, { kind: 'layout', filePath: layoutFile });
586
858
  if (layoutParsedForImports.serverImports.length > 0) {
587
859
  const layoutFileDir = routesDir;
588
860
  const outFileDir = path.join(projectDir, '.kuratchi');
@@ -592,39 +864,7 @@ export function compile(options) {
592
864
  if (!pathMatch)
593
865
  continue;
594
866
  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
- }
867
+ const importPath = resolveCompiledImportPath(origPath, layoutFileDir, outFileDir);
628
868
  const moduleId = `__m${moduleCounter++}`;
629
869
  allImports.push(`import * as ${moduleId} from '${importPath}';`);
630
870
  const namesMatch = imp.match(/import\s*\{([^}]+)\}/);
@@ -665,7 +905,7 @@ export function compile(options) {
665
905
  }
666
906
  }
667
907
  }
668
- // Detect if the compiled layout uses await → make it async
908
+ // Detect if the compiled layout uses await â†' make it async
669
909
  isLayoutAsync = /\bawait\b/.test(compiledLayout);
670
910
  if (isLayoutAsync) {
671
911
  compiledLayout = compiledLayout.replace(/^function __layout\(/, 'async function __layout(');
@@ -674,72 +914,62 @@ export function compile(options) {
674
914
  for (let i = 0; i < routeFiles.length; i++) {
675
915
  const rf = routeFiles[i];
676
916
  const fullPath = path.join(routesDir, rf.file);
677
- const source = fs.readFileSync(fullPath, 'utf-8');
678
- const parsed = parseFile(source);
679
917
  const pattern = filePathToPattern(rf.name);
680
- // Build a mapping: functionName → moduleId for all imports in this route
918
+ // -- API route (index.ts / index.js) --
919
+ if (rf.type === 'api') {
920
+ const outFileDir = path.join(projectDir, '.kuratchi');
921
+ // Resolve the route file's absolute path through transformServerModule
922
+ // so that $-prefixed imports inside it get rewritten correctly
923
+ const absRoutePath = transformServerModule(fullPath);
924
+ let importPath = path.relative(outFileDir, absRoutePath).replace(/\\/g, '/');
925
+ if (!importPath.startsWith('.'))
926
+ importPath = './' + importPath;
927
+ const moduleId = `__m${moduleCounter++}`;
928
+ allImports.push(`import * as ${moduleId} from '${importPath}';`);
929
+ // Scan the source for exported method handlers (only include what exists)
930
+ const apiSource = fs.readFileSync(fullPath, 'utf-8');
931
+ const allMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
932
+ const exportedMethods = allMethods.filter(m => {
933
+ // Match: export function GET, export async function GET, export { ... as GET }
934
+ const fnPattern = new RegExp(`export\\s+(async\\s+)?function\\s+${m}\\b`);
935
+ const reExportPattern = new RegExp(`export\\s*\\{[^}]*\\b\\w+\\s+as\\s+${m}\\b`);
936
+ const namedExportPattern = new RegExp(`export\\s*\\{[^}]*\\b${m}\\b`);
937
+ return fnPattern.test(apiSource) || reExportPattern.test(apiSource) || namedExportPattern.test(apiSource);
938
+ });
939
+ const methodEntries = exportedMethods
940
+ .map(m => `${m}: ${moduleId}.${m}`)
941
+ .join(', ');
942
+ compiledRoutes.push(`{ pattern: '${pattern}', __api: true, ${methodEntries} }`);
943
+ continue;
944
+ }
945
+ // ── Page route (page.html) ──
946
+ const source = fs.readFileSync(fullPath, 'utf-8');
947
+ const parsed = parseFile(source, { kind: 'route', filePath: fullPath });
948
+ // Build a mapping: functionName â†' moduleId for all imports in this route
681
949
  const fnToModule = {};
682
950
  const outFileDir = path.join(projectDir, '.kuratchi');
683
- if (parsed.serverImports.length > 0) {
951
+ const neededServerFns = new Set([
952
+ ...parsed.actionFunctions,
953
+ ...parsed.pollFunctions,
954
+ ...parsed.dataGetQueries.map((q) => q.fnName),
955
+ ]);
956
+ const routeServerImports = parsed.serverImports.length > 0
957
+ ? parsed.serverImports
958
+ : filterClientImportsForServer(parsed.clientImports, neededServerFns);
959
+ if (routeServerImports.length > 0) {
684
960
  const routeFileDir = path.dirname(fullPath);
685
- for (const imp of parsed.serverImports) {
961
+ for (const imp of routeServerImports) {
686
962
  const pathMatch = imp.match(/from\s+['"]([^'"]+)['"]/);
687
963
  if (!pathMatch)
688
964
  continue;
689
965
  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
- }
966
+ // Bare module specifiers (packages) â€" pass through as-is
967
+ const importPath = resolveCompiledImportPath(origPath, routeFileDir, outFileDir);
729
968
  const moduleId = `__m${moduleCounter++}`;
730
969
  allImports.push(`import * as ${moduleId} from '${importPath}';`);
731
970
  // 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);
971
+ const names = parseNamedImportBindings(imp);
972
+ if (names.length > 0) {
743
973
  for (const name of names) {
744
974
  fnToModule[name] = moduleId;
745
975
  }
@@ -752,7 +982,7 @@ export function compile(options) {
752
982
  }
753
983
  }
754
984
  // Build per-route component names from explicit imports
755
- // componentImports: { StatCard: 'stat-card' } → componentNames maps PascalCase → fileName
985
+ // componentImports: { StatCard: 'stat-card' } â†' componentNames maps PascalCase â†' fileName
756
986
  const routeComponentNames = new Map();
757
987
  for (const [pascalName, fileName] of Object.entries(parsed.componentImports)) {
758
988
  // Compile the component on first use
@@ -767,22 +997,22 @@ export function compile(options) {
767
997
  // We then scan the route template for that component's usage and extract the bound values.
768
998
  for (const [pascalName, compFileName] of routeComponentNames.entries()) {
769
999
  const actionPropNames = componentActionCache.get(compFileName);
770
- if (!actionPropNames || actionPropNames.size === 0)
771
- continue;
772
1000
  // Find all usages of <PascalName ...> in the route template and extract prop bindings.
773
1001
  // Match <ComponentName ... propName={value} ... > across multiple lines.
774
1002
  const compTagRegex = new RegExp(`<${pascalName}\\b([\\s\\S]*?)/>`, 'g');
775
1003
  for (const tagMatch of parsed.template.matchAll(compTagRegex)) {
776
1004
  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);
1005
+ if (actionPropNames && actionPropNames.size > 0) {
1006
+ for (const propName of actionPropNames) {
1007
+ // Find propName={identifier} binding
1008
+ const propRegex = new RegExp(`\\b${propName}=\\{([A-Za-z_$][\\w$]*)\\}`);
1009
+ const propMatch = attrs.match(propRegex);
1010
+ if (propMatch) {
1011
+ const routeFnName = propMatch[1];
1012
+ // Only add if this function is actually imported by the route
1013
+ if (routeFnName in fnToModule && !parsed.actionFunctions.includes(routeFnName)) {
1014
+ parsed.actionFunctions.push(routeFnName);
1015
+ }
786
1016
  }
787
1017
  }
788
1018
  }
@@ -791,7 +1021,7 @@ export function compile(options) {
791
1021
  // Compile template to render function body (pass component names and action names)
792
1022
  // An identifier is a valid server action if it is either:
793
1023
  // 1. Directly imported (present in fnToModule), or
794
- // 2. A top-level script declaration (present in dataVars) — covers cases like
1024
+ // 2. A top-level script declaration (present in dataVars) â€" covers cases like
795
1025
  // `const fn = importedFn` or `async function fn() {}` where the binding
796
1026
  // is locally declared but delegates to an imported function.
797
1027
  const dataVarsSet = new Set(parsed.dataVars);
@@ -869,7 +1099,16 @@ export function compile(options) {
869
1099
  // Collect only the components that were actually imported by routes
870
1100
  const compiledComponents = Array.from(compiledComponentCache.values());
871
1101
  // Generate the routes module
872
- const runtimeImportPath = resolveRuntimeImportPath(projectDir);
1102
+ const rawRuntimeImportPath = resolveRuntimeImportPath(projectDir);
1103
+ let runtimeImportPath;
1104
+ if (rawRuntimeImportPath) {
1105
+ // Resolve the runtime file's absolute path and pass it through transformServerModule
1106
+ // so that $durable-objects/* and other project imports get rewritten to their proxies.
1107
+ const runtimeAbs = path.resolve(path.join(projectDir, '.kuratchi'), rawRuntimeImportPath);
1108
+ const transformedRuntimePath = transformServerModule(runtimeAbs);
1109
+ const outFile = options.outFile ?? path.join(projectDir, '.kuratchi', 'routes.js');
1110
+ runtimeImportPath = toModuleSpecifier(outFile, transformedRuntimePath);
1111
+ }
873
1112
  const hasRuntime = !!runtimeImportPath;
874
1113
  const output = generateRoutesModule({
875
1114
  projectDir,
@@ -887,7 +1126,7 @@ export function compile(options) {
887
1126
  isLayoutAsync,
888
1127
  compiledLayoutActions,
889
1128
  hasRuntime,
890
- runtimeImportPath: runtimeImportPath ?? undefined,
1129
+ runtimeImportPath,
891
1130
  });
892
1131
  // Write to .kuratchi/routes.js
893
1132
  const outFile = options.outFile ?? path.join(projectDir, '.kuratchi', 'routes.js');
@@ -919,7 +1158,7 @@ export function compile(options) {
919
1158
  writeIfChanged(workerFile, workerLines.join('\n'));
920
1159
  return workerFile;
921
1160
  }
922
- // ── Helpers ─────────────────────────────────────────────────────────────
1161
+ // â"€â"€ Helpers â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
923
1162
  /**
924
1163
  * Write a file only if its content has changed.
925
1164
  * Prevents unnecessary filesystem events that would retrigger wrangler's file watcher.
@@ -1013,7 +1252,7 @@ function readUiTheme(projectDir) {
1013
1252
  console.warn('[kuratchi] ui.theme: "default" configured but @kuratchi/ui theme.css not found');
1014
1253
  return null;
1015
1254
  }
1016
- // Custom path — resolve relative to project root
1255
+ // Custom path â€" resolve relative to project root
1017
1256
  const customPath = path.resolve(projectDir, themeValue);
1018
1257
  if (fs.existsSync(customPath)) {
1019
1258
  return fs.readFileSync(customPath, 'utf-8');
@@ -1031,7 +1270,7 @@ function resolvePackageComponent(projectDir, pkgName, componentFile) {
1031
1270
  if (fs.existsSync(nmPath))
1032
1271
  return nmPath;
1033
1272
  // 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)
1273
+ // @kuratchi/ui â†' kuratchi-ui (convention: scope stripped, slash â†' dash)
1035
1274
  const pkgDirName = pkgName.replace(/^@/, '').replace(/\//g, '-');
1036
1275
  const workspaceRoot = path.resolve(projectDir, '../..');
1037
1276
  const wsPath = path.join(workspaceRoot, 'packages', pkgDirName, 'src', 'lib', componentFile + '.html');
@@ -1069,32 +1308,49 @@ function discoverRoutes(routesDir) {
1069
1308
  for (const entry of entries) {
1070
1309
  if (entry.isDirectory()) {
1071
1310
  const childPrefix = prefix ? `${prefix}/${entry.name}` : entry.name;
1072
- // Folder-based route: routes/db/page.html → /db
1311
+ // Folder-based page route: routes/db/page.html /db
1073
1312
  const pageFile = path.join(dir, entry.name, 'page.html');
1074
1313
  if (fs.existsSync(pageFile)) {
1075
1314
  const routeFile = `${childPrefix}/page.html`;
1076
1315
  if (!registered.has(routeFile)) {
1077
1316
  registered.add(routeFile);
1078
- results.push({ file: routeFile, name: childPrefix, layouts: getLayoutsForPrefix(childPrefix) });
1317
+ results.push({ file: routeFile, name: childPrefix, layouts: getLayoutsForPrefix(childPrefix), type: 'page' });
1318
+ }
1319
+ }
1320
+ // Folder-based API route: routes/api/v1/health/index.ts -> /api/v1/health
1321
+ const apiFile = ['index.ts', 'index.js'].find(f => fs.existsSync(path.join(dir, entry.name, f)));
1322
+ if (apiFile && !fs.existsSync(pageFile)) {
1323
+ const routeFile = `${childPrefix}/${apiFile}`;
1324
+ if (!registered.has(routeFile)) {
1325
+ registered.add(routeFile);
1326
+ results.push({ file: routeFile, name: childPrefix, layouts: [], type: 'api' });
1079
1327
  }
1080
1328
  }
1081
1329
  // Always recurse into subdirectory (for nested routes like /admin/roles)
1082
1330
  walk(path.join(dir, entry.name), childPrefix);
1083
1331
  }
1084
1332
  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
1333
+ // Skip layout.html is the app layout, 404/500 are error pages, not routes
1086
1334
  continue;
1087
1335
  }
1336
+ else if (entry.name === 'index.ts' || entry.name === 'index.js') {
1337
+ // API route file in current directory -> index API route for this prefix
1338
+ const routeFile = prefix ? `${prefix}/${entry.name}` : entry.name;
1339
+ if (!registered.has(routeFile)) {
1340
+ registered.add(routeFile);
1341
+ results.push({ file: routeFile, name: prefix || 'index', layouts: [], type: 'api' });
1342
+ }
1343
+ }
1088
1344
  else if (entry.name === 'page.html') {
1089
- // page.html in current directory → index route for this prefix
1345
+ // page.html in current directory index route for this prefix
1090
1346
  const routeFile = prefix ? `${prefix}/page.html` : 'page.html';
1091
1347
  if (!registered.has(routeFile)) {
1092
1348
  registered.add(routeFile);
1093
- results.push({ file: routeFile, name: prefix || 'index', layouts: getLayoutsForPrefix(prefix) });
1349
+ results.push({ file: routeFile, name: prefix || 'index', layouts: getLayoutsForPrefix(prefix), type: 'page' });
1094
1350
  }
1095
1351
  }
1096
1352
  else if (entry.name.endsWith('.html') && entry.name !== 'page.html') {
1097
- // File-based route: routes/about.html → /about (fallback)
1353
+ // File-based route: routes/about.html /about (fallback)
1098
1354
  const name = prefix
1099
1355
  ? `${prefix}/${entry.name.replace('.html', '')}`
1100
1356
  : entry.name.replace('.html', '');
@@ -1102,6 +1358,7 @@ function discoverRoutes(routesDir) {
1102
1358
  file: prefix ? `${prefix}/${entry.name}` : entry.name,
1103
1359
  name,
1104
1360
  layouts: getLayoutsForPrefix(prefix),
1361
+ type: 'page',
1105
1362
  });
1106
1363
  }
1107
1364
  }
@@ -1120,18 +1377,48 @@ function buildRouteObject(opts) {
1120
1377
  const hasFns = Object.keys(fnToModule).length > 0;
1121
1378
  const parts = [];
1122
1379
  parts.push(` pattern: '${pattern}'`);
1123
- // Load function — generated from the script block's top-level code + data-get queries
1380
+ const queryVars = parsed.dataGetQueries?.map((q) => q.asName) ?? [];
1381
+ let scriptBody = parsed.script
1382
+ ? parsed.script.replace(/^\s*import[\s\S]*?from\s+['"][^'"]+['"]\s*;?/gm, '').trim()
1383
+ : '';
1384
+ scriptBody = rewriteImportedFunctionCalls(scriptBody, fnToModule);
1385
+ scriptBody = rewriteWorkerEnvAliases(scriptBody, parsed.workerEnvAliases);
1386
+ let explicitLoadFunction = parsed.loadFunction
1387
+ ? parsed.loadFunction.replace(/^export\s+/, '').trim()
1388
+ : '';
1389
+ if (explicitLoadFunction) {
1390
+ explicitLoadFunction = rewriteImportedFunctionCalls(explicitLoadFunction, fnToModule);
1391
+ explicitLoadFunction = rewriteWorkerEnvAliases(explicitLoadFunction, parsed.workerEnvAliases);
1392
+ }
1393
+ if (explicitLoadFunction && /\bawait\b/.test(scriptBody)) {
1394
+ throw new Error(`[kuratchi compiler] ${pattern}\nTop-level await cannot be mixed with export async function load(). Move async server work into load().`);
1395
+ }
1396
+ if (scriptBody) {
1397
+ scriptBody = transpileTypeScript(scriptBody, `route-script:${pattern}.ts`);
1398
+ }
1399
+ if (explicitLoadFunction) {
1400
+ explicitLoadFunction = transpileTypeScript(explicitLoadFunction, `route-load:${pattern}.ts`);
1401
+ }
1402
+ const scriptUsesAwait = /\bawait\b/.test(scriptBody);
1403
+ const scriptReturnVars = parsed.script
1404
+ ? parsed.dataVars.filter((v) => !queryVars.includes(v))
1405
+ : [];
1406
+ // Load function â€" internal server prepass for async route script bodies
1407
+ // and data-get query state hydration.
1124
1408
  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
- : '';
1409
+ if (explicitLoadFunction) {
1410
+ parts.push(` load: ${explicitLoadFunction}`);
1411
+ }
1412
+ else if ((scriptBody && scriptUsesAwait) || hasDataGetQueries) {
1413
+ let loadBody = '';
1414
+ if (scriptBody && scriptUsesAwait) {
1415
+ loadBody = scriptBody;
1416
+ }
1130
1417
  // Inject data-get query state blocks into load scope.
1131
1418
  // Each query exposes:
1132
1419
  // { state, loading, error, data, empty, success }
1420
+ const queries = parsed.dataGetQueries;
1133
1421
  if (hasDataGetQueries) {
1134
- const queries = parsed.dataGetQueries;
1135
1422
  const queryLines = [];
1136
1423
  for (const q of queries) {
1137
1424
  const fnName = q.fnName;
@@ -1154,25 +1441,13 @@ function buildRouteObject(opts) {
1154
1441
  queryLines.push(` }`);
1155
1442
  queryLines.push(`}`);
1156
1443
  }
1157
- body = [body, queryLines.join('\n')].filter(Boolean).join('\n');
1444
+ loadBody = [loadBody, queryLines.join('\n')].filter(Boolean).join('\n');
1158
1445
  }
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 }`);
1446
+ const loadReturnVars = [...scriptReturnVars, ...queryVars];
1447
+ const returnObj = loadReturnVars.length > 0 ? `\n return { ${loadReturnVars.join(', ')} };` : '';
1448
+ parts.push(` async load(params = {}) {\n ${loadBody}${returnObj}\n }`);
1174
1449
  }
1175
- // Actions — functions referenced via action={fn} in the template
1450
+ // Actions â€" functions referenced via action={fn} in the template
1176
1451
  if (hasFns && parsed.actionFunctions.length > 0) {
1177
1452
  const actionEntries = parsed.actionFunctions
1178
1453
  .map(fn => {
@@ -1182,7 +1457,7 @@ function buildRouteObject(opts) {
1182
1457
  .join(', ');
1183
1458
  parts.push(` actions: { ${actionEntries} }`);
1184
1459
  }
1185
- // RPC — functions referenced via data-poll={fn(args)} in the template
1460
+ // RPC â€" functions referenced via data-poll={fn(args)} in the template
1186
1461
  if (hasFns && parsed.pollFunctions.length > 0) {
1187
1462
  const rpcEntries = parsed.pollFunctions
1188
1463
  .map(fn => {
@@ -1193,12 +1468,21 @@ function buildRouteObject(opts) {
1193
1468
  .join(', ');
1194
1469
  parts.push(` rpc: { ${rpcEntries} }`);
1195
1470
  }
1196
- // Render function — template compiled to JS with native flow control
1471
+ // Render function â€" template compiled to JS with native flow control
1197
1472
  // 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');
1473
+ // Auto-inject action state objects so templates can reference signIn.error, signIn.loading, etc.
1474
+ const renderPrelude = (scriptBody && !scriptUsesAwait) ? scriptBody : '';
1475
+ const allVars = [...queryVars];
1476
+ if (scriptUsesAwait) {
1477
+ for (const v of scriptReturnVars) {
1478
+ if (!allVars.includes(v))
1479
+ allVars.push(v);
1480
+ }
1481
+ }
1482
+ for (const fn of parsed.actionFunctions) {
1483
+ if (!allVars.includes(fn))
1484
+ allVars.push(fn);
1485
+ }
1202
1486
  if (!allVars.includes('params'))
1203
1487
  allVars.push('params');
1204
1488
  if (!allVars.includes('breadcrumbs'))
@@ -1213,7 +1497,7 @@ function buildRouteObject(opts) {
1213
1497
  finalRenderBody = [lines[0], ...styleLines, ...lines.slice(1)].join('\n');
1214
1498
  }
1215
1499
  parts.push(` render(data) {
1216
- ${destructure}${finalRenderBody}
1500
+ ${destructure}${renderPrelude ? renderPrelude + '\n ' : ''}${finalRenderBody}
1217
1501
  return __html;
1218
1502
  }`);
1219
1503
  return ` {\n${parts.join(',\n')}\n }`;
@@ -1227,7 +1511,7 @@ function readOrmConfig(projectDir) {
1227
1511
  if (!ormBlock)
1228
1512
  return [];
1229
1513
  // Extract schema imports: import { todoSchema } from './src/schemas/todo';
1230
- const importMap = new Map(); // exportName → importPath
1514
+ const importMap = new Map(); // exportName â†' importPath
1231
1515
  const importRegex = /import\s*\{\s*([^}]+)\s*\}\s*from\s*['"]([^'"]+)['"]/g;
1232
1516
  let m;
1233
1517
  while ((m = importRegex.exec(source)) !== null) {
@@ -1364,7 +1648,7 @@ function readDoConfig(projectDir) {
1364
1648
  if (list.length > 0)
1365
1649
  entry.files = list;
1366
1650
  }
1367
- // (inject config removed — DO methods are org-scoped, no auto-injection needed)
1651
+ // (inject config removed â€" DO methods are org-scoped, no auto-injection needed)
1368
1652
  entries.push(entry);
1369
1653
  }
1370
1654
  // Match string shorthand: BINDING: 'ClassName' (skip bindings already found)
@@ -1482,9 +1766,9 @@ function discoverFilesWithSuffix(dir, suffix) {
1482
1766
  return out;
1483
1767
  }
1484
1768
  /**
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.
1769
+ * Scan DO handler files.
1770
+ * - Class mode: default class extends kuratchiDO
1771
+ * - Function mode: exported functions in *.do.ts files (compiler wraps into DO class)
1488
1772
  */
1489
1773
  function discoverDoHandlers(srcDir, doConfig, ormDatabases) {
1490
1774
  const serverDir = path.join(srcDir, 'server');
@@ -1512,14 +1796,15 @@ function discoverDoHandlers(srcDir, doConfig, ormDatabases) {
1512
1796
  for (const absPath of discoveredFiles) {
1513
1797
  const file = path.basename(absPath);
1514
1798
  const source = fs.readFileSync(absPath, 'utf-8');
1515
- // Check if this file extends kuratchiDO
1516
- if (!/extends\s+kuratchiDO\b/.test(source))
1799
+ const exportedFunctions = extractExportedFunctions(source);
1800
+ const hasClass = /extends\s+kuratchiDO\b/.test(source);
1801
+ if (!hasClass && exportedFunctions.length === 0)
1517
1802
  continue;
1518
- // Extract class name: export default class Sites extends kuratchiDO
1803
+ // Extract class name when class mode is used.
1519
1804
  const classMatch = source.match(/export\s+default\s+class\s+(\w+)\s+extends\s+kuratchiDO/);
1520
- if (!classMatch)
1805
+ const className = classMatch?.[1] ?? null;
1806
+ if (hasClass && !className)
1521
1807
  continue;
1522
- const className = classMatch[1];
1523
1808
  // Binding resolution:
1524
1809
  // 1) explicit static binding in class
1525
1810
  // 2) config-mapped file name (supports .do.ts convention)
@@ -1544,10 +1829,8 @@ function discoverDoHandlers(srcDir, doConfig, ormDatabases) {
1544
1829
  continue;
1545
1830
  if (!bindings.has(binding))
1546
1831
  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);
1832
+ // Extract class methods in class mode
1833
+ const classMethods = className ? extractClassMethods(source, className) : [];
1551
1834
  const fileName = file.replace(/\.ts$/, '');
1552
1835
  const existing = fileNameToAbsPath.get(fileName);
1553
1836
  if (existing && existing !== absPath) {
@@ -1558,9 +1841,10 @@ function discoverDoHandlers(srcDir, doConfig, ormDatabases) {
1558
1841
  fileName,
1559
1842
  absPath,
1560
1843
  binding,
1561
- className,
1844
+ mode: hasClass ? 'class' : 'function',
1845
+ className: className ?? undefined,
1562
1846
  classMethods,
1563
- namedExports,
1847
+ exportedFunctions,
1564
1848
  });
1565
1849
  }
1566
1850
  return handlers;
@@ -1652,40 +1936,33 @@ function extractClassMethods(source, className) {
1652
1936
  }
1653
1937
  return methods;
1654
1938
  }
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
1939
+ function extractExportedFunctions(source) {
1940
+ const out = [];
1661
1941
  const fnRegex = /export\s+(?:async\s+)?function\s+(\w+)/g;
1662
1942
  let m;
1663
1943
  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;
1944
+ out.push(m[1]);
1945
+ return out;
1670
1946
  }
1671
1947
  /**
1672
1948
  * Generate a proxy module for a DO handler file.
1673
1949
  *
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).
1950
+ * The proxy provides auto-RPC function exports.
1951
+ * - Class mode: public class methods become RPC exports.
1952
+ * - Function mode: exported functions become RPC exports, excluding lifecycle hooks.
1679
1953
  */
1680
1954
  function generateHandlerProxy(handler, projectDir) {
1681
1955
  const doDir = path.join(projectDir, '.kuratchi', 'do');
1682
1956
  const origRelPath = path.relative(doDir, handler.absPath).replace(/\\/g, '/').replace(/\.ts$/, '.js');
1683
1957
  const handlerLocal = `__handler_${toSafeIdentifier(handler.fileName)}`;
1684
- const customSet = new Set(handler.namedExports);
1958
+ const lifecycle = new Set(['onInit', 'onAlarm', 'onMessage']);
1959
+ const rpcFunctions = handler.mode === 'function'
1960
+ ? handler.exportedFunctions.filter((n) => !lifecycle.has(n))
1961
+ : handler.classMethods.filter((m) => m.visibility === 'public').map((m) => m.name);
1685
1962
  const methods = handler.classMethods.map((m) => ({ ...m }));
1686
1963
  const methodMap = new Map(methods.map((m) => [m.name, m]));
1687
1964
  let changed = true;
1688
- while (changed) {
1965
+ while (changed && handler.mode === 'class') {
1689
1966
  changed = false;
1690
1967
  for (const m of methods) {
1691
1968
  if (m.hasWorkerContextCalls)
@@ -1700,15 +1977,56 @@ function generateHandlerProxy(handler, projectDir) {
1700
1977
  }
1701
1978
  }
1702
1979
  }
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);
1980
+ const workerContextMethods = handler.mode === 'class'
1981
+ ? methods.filter((m) => m.visibility === 'public' && m.hasWorkerContextCalls).map((m) => m.name)
1982
+ : [];
1983
+ const asyncMethods = handler.mode === 'class'
1984
+ ? methods.filter((m) => m.isAsync).map((m) => m.name)
1985
+ : [];
1708
1986
  const lines = [
1709
- `// Auto-generated by KuratchiJS compiler — do not edit.`,
1987
+ `// Auto-generated by KuratchiJS compiler â€" do not edit.`,
1710
1988
  `import { __getDoStub } from '${RUNTIME_DO_IMPORT}';`,
1711
- `import ${handlerLocal} from '${origRelPath}';`,
1989
+ ...(handler.mode === 'class' ? [`import ${handlerLocal} from '${origRelPath}';`] : []),
1990
+ ``,
1991
+ `const __FD_TAG = '__kuratchi_form_data__';`,
1992
+ `function __isPlainObject(__v) {`,
1993
+ ` if (!__v || typeof __v !== 'object') return false;`,
1994
+ ` const __proto = Object.getPrototypeOf(__v);`,
1995
+ ` return __proto === Object.prototype || __proto === null;`,
1996
+ `}`,
1997
+ `function __encodeArg(__v, __seen = new WeakSet()) {`,
1998
+ ` if (typeof FormData !== 'undefined' && __v instanceof FormData) {`,
1999
+ ` return { [__FD_TAG]: Array.from(__v.entries()) };`,
2000
+ ` }`,
2001
+ ` if (Array.isArray(__v)) return __v.map((__x) => __encodeArg(__x, __seen));`,
2002
+ ` if (__isPlainObject(__v)) {`,
2003
+ ` if (__seen.has(__v)) throw new Error('[KuratchiJS] Circular object passed to DO RPC');`,
2004
+ ` __seen.add(__v);`,
2005
+ ` const __out = {};`,
2006
+ ` for (const [__k, __val] of Object.entries(__v)) __out[__k] = __encodeArg(__val, __seen);`,
2007
+ ` __seen.delete(__v);`,
2008
+ ` return __out;`,
2009
+ ` }`,
2010
+ ` return __v;`,
2011
+ `}`,
2012
+ `function __decodeArg(__v) {`,
2013
+ ` if (Array.isArray(__v)) return __v.map(__decodeArg);`,
2014
+ ` if (__isPlainObject(__v)) {`,
2015
+ ` const __obj = __v;`,
2016
+ ` if (__FD_TAG in __obj) {`,
2017
+ ` const __fd = new FormData();`,
2018
+ ` const __entries = Array.isArray(__obj[__FD_TAG]) ? __obj[__FD_TAG] : [];`,
2019
+ ` for (const __pair of __entries) {`,
2020
+ ` if (Array.isArray(__pair) && __pair.length >= 2) __fd.append(String(__pair[0]), __pair[1]);`,
2021
+ ` }`,
2022
+ ` return __fd;`,
2023
+ ` }`,
2024
+ ` const __out = {};`,
2025
+ ` for (const [__k, __val] of Object.entries(__obj)) __out[__k] = __decodeArg(__val);`,
2026
+ ` return __out;`,
2027
+ ` }`,
2028
+ ` return __v;`,
2029
+ `}`,
1712
2030
  ``,
1713
2031
  ];
1714
2032
  if (workerContextMethods.length > 0) {
@@ -1728,29 +2046,22 @@ function generateHandlerProxy(handler, projectDir) {
1728
2046
  lines.push(` if (typeof __local === 'function' && !__asyncMethods.has(__k)) {`);
1729
2047
  lines.push(` return (...__a) => __local.apply(__self, __a);`);
1730
2048
  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); };`);
2049
+ 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
2050
  lines.push(` },`);
1733
2051
  lines.push(` });`);
1734
- lines.push(` return ${handlerLocal}.prototype[__name].apply(__self, __args);`);
2052
+ lines.push(` return ${handlerLocal}.prototype[__name].apply(__self, __args.map(__decodeArg));`);
1735
2053
  lines.push(`}`);
1736
2054
  lines.push(``);
1737
2055
  }
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
2056
+ // Export RPC methods
2057
+ for (const method of rpcFunctions) {
1742
2058
  if (workerContextMethods.includes(method)) {
1743
2059
  lines.push(`export async function ${method}(...a) { return __callWorkerMethod('${method}', a); }`);
1744
2060
  }
1745
2061
  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); }`);
2062
+ 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
2063
  }
1748
2064
  }
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
2065
  return lines.join('\n') + '\n';
1755
2066
  }
1756
2067
  function generateRoutesModule(opts) {
@@ -1763,16 +2074,16 @@ function generateRoutesModule(opts) {
1763
2074
  .map(([status, fn]) => fn)
1764
2075
  .join('\n\n');
1765
2076
  // 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}';`;
2077
+ const contextImport = `import { __setRequestContext, __esc, __rawHtml, __sanitizeHtml, __setLocal, __getLocals, buildDefaultBreadcrumbs as __buildDefaultBreadcrumbs } from '${RUNTIME_CONTEXT_IMPORT}';`;
1767
2078
  const runtimeImport = opts.hasRuntime && opts.runtimeImportPath
1768
2079
  ? `import __kuratchiRuntime from '${opts.runtimeImportPath}';`
1769
2080
  : '';
1770
- // Auth session init — thin cookie parsing injected into Worker entry
2081
+ // Auth session init â€" thin cookie parsing injected into Worker entry
1771
2082
  let authInit = '';
1772
2083
  if (opts.authConfig && opts.authConfig.sessionEnabled) {
1773
2084
  const cookieName = opts.authConfig.cookieName;
1774
2085
  authInit = `
1775
- // ── Auth Session Init ───────────────────────────────────────
2086
+ // â"€â"€ Auth Session Init â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
1776
2087
 
1777
2088
  function __parseCookies(header) {
1778
2089
  const map = {};
@@ -1823,7 +2134,7 @@ function __initAuth(request) {
1823
2134
  ...schemaImports,
1824
2135
  ].join('\n');
1825
2136
  migrationInit = `
1826
- // ── ORM Auto-Migration ──────────────────────────────────────
2137
+ // â"€â"€ ORM Auto-Migration â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
1827
2138
 
1828
2139
  let __migrated = false;
1829
2140
  const __ormDatabases = [
@@ -1857,7 +2168,7 @@ async function __runMigrations() {
1857
2168
  `;
1858
2169
  }
1859
2170
  }
1860
- // Auth plugin init — import config + call @kuratchi/auth setup functions
2171
+ // Auth plugin init â€" import config + call @kuratchi/auth setup functions
1861
2172
  let authPluginImports = '';
1862
2173
  let authPluginInit = '';
1863
2174
  const ac = opts.authConfig;
@@ -1909,14 +2220,14 @@ async function __runMigrations() {
1909
2220
  }
1910
2221
  authPluginImports = imports.join('\n');
1911
2222
  authPluginInit = `
1912
- // ── Auth Plugin Init ───────────────────────────────────────
2223
+ // â"€â"€ Auth Plugin Init â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
1913
2224
 
1914
2225
  function __initAuthPlugins() {
1915
2226
  ${initLines.join('\n')}
1916
2227
  }
1917
2228
  `;
1918
2229
  }
1919
- // ── Durable Object class generation ───────────────────────────
2230
+ // â"€â"€ Durable Object class generation â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
1920
2231
  let doImports = '';
1921
2232
  let doClassCode = '';
1922
2233
  let doResolverInit = '';
@@ -1926,7 +2237,28 @@ ${initLines.join('\n')}
1926
2237
  const doResolverLines = [];
1927
2238
  doImportLines.push(`import { DurableObject as __DO } from 'cloudflare:workers';`);
1928
2239
  doImportLines.push(`import { initDO as __initDO } from '@kuratchi/orm';`);
1929
- doImportLines.push(`import { __registerDoResolver, __registerDoClassBinding } from '${RUNTIME_DO_IMPORT}';`);
2240
+ doImportLines.push(`import { __registerDoResolver, __registerDoClassBinding, __setDoContext } from '${RUNTIME_DO_IMPORT}';`);
2241
+ doImportLines.push(`const __DO_FD_TAG = '__kuratchi_form_data__';`);
2242
+ doImportLines.push(`function __isDoPlainObject(__v) {`);
2243
+ doImportLines.push(` if (!__v || typeof __v !== 'object') return false;`);
2244
+ doImportLines.push(` const __proto = Object.getPrototypeOf(__v);`);
2245
+ doImportLines.push(` return __proto === Object.prototype || __proto === null;`);
2246
+ doImportLines.push(`}`);
2247
+ doImportLines.push(`function __decodeDoArg(__v) {`);
2248
+ doImportLines.push(` if (Array.isArray(__v)) return __v.map(__decodeDoArg);`);
2249
+ doImportLines.push(` if (__isDoPlainObject(__v)) {`);
2250
+ doImportLines.push(` if (__DO_FD_TAG in __v) {`);
2251
+ doImportLines.push(` const __fd = new FormData();`);
2252
+ doImportLines.push(` const __entries = Array.isArray(__v[__DO_FD_TAG]) ? __v[__DO_FD_TAG] : [];`);
2253
+ doImportLines.push(` for (const __pair of __entries) { if (Array.isArray(__pair) && __pair.length >= 2) __fd.append(String(__pair[0]), __pair[1]); }`);
2254
+ doImportLines.push(` return __fd;`);
2255
+ doImportLines.push(` }`);
2256
+ doImportLines.push(` const __out = {};`);
2257
+ doImportLines.push(` for (const [__k, __val] of Object.entries(__v)) __out[__k] = __decodeDoArg(__val);`);
2258
+ doImportLines.push(` return __out;`);
2259
+ doImportLines.push(` }`);
2260
+ doImportLines.push(` return __v;`);
2261
+ doImportLines.push(`}`);
1930
2262
  // We need getCurrentUser and getOrgStubByName for stub resolvers
1931
2263
  doImportLines.push(`import { getCurrentUser as __getCU, getOrgStubByName as __getOSBN } from '@kuratchi/auth';`);
1932
2264
  // Group handlers by binding
@@ -1940,6 +2272,10 @@ ${initLines.join('\n')}
1940
2272
  for (const doEntry of opts.doConfig) {
1941
2273
  const handlers = handlersByBinding.get(doEntry.binding) ?? [];
1942
2274
  const ormDb = opts.ormDatabases.find(d => d.binding === doEntry.binding);
2275
+ const fnHandlers = handlers.filter((h) => h.mode === 'function');
2276
+ const initHandlers = fnHandlers.filter((h) => h.exportedFunctions.includes('onInit'));
2277
+ const alarmHandlers = fnHandlers.filter((h) => h.exportedFunctions.includes('onAlarm'));
2278
+ const messageHandlers = fnHandlers.filter((h) => h.exportedFunctions.includes('onMessage'));
1943
2279
  // Import schema (paths are relative to project root; prefix ../ since we're in .kuratchi/)
1944
2280
  if (ormDb) {
1945
2281
  const schemaPath = ormDb.schemaImportPath.replace(/^\.\//, '../');
@@ -1954,7 +2290,12 @@ ${initLines.join('\n')}
1954
2290
  if (!handlerImportPath.startsWith('.'))
1955
2291
  handlerImportPath = './' + handlerImportPath;
1956
2292
  const handlerVar = `__handler_${toSafeIdentifier(h.fileName)}`;
1957
- doImportLines.push(`import ${handlerVar} from '${handlerImportPath}';`);
2293
+ if (h.mode === 'class') {
2294
+ doImportLines.push(`import ${handlerVar} from '${handlerImportPath}';`);
2295
+ }
2296
+ else {
2297
+ doImportLines.push(`import * as ${handlerVar} from '${handlerImportPath}';`);
2298
+ }
1958
2299
  }
1959
2300
  // Generate DO class
1960
2301
  doClassLines.push(`export class ${doEntry.className} extends __DO {`);
@@ -1963,6 +2304,11 @@ ${initLines.join('\n')}
1963
2304
  if (ormDb) {
1964
2305
  doClassLines.push(` this.db = __initDO(ctx.storage.sql, __doSchema_${doEntry.binding});`);
1965
2306
  }
2307
+ for (const h of initHandlers) {
2308
+ const handlerVar = `__handler_${toSafeIdentifier(h.fileName)}`;
2309
+ doClassLines.push(` __setDoContext(this);`);
2310
+ doClassLines.push(` Promise.resolve(${handlerVar}.onInit.call(this)).catch((err) => console.error('[kuratchi] DO onInit failed:', err?.message || err));`);
2311
+ }
1966
2312
  doClassLines.push(` }`);
1967
2313
  if (ormDb) {
1968
2314
  doClassLines.push(` async __kuratchiLogActivity(payload) {`);
@@ -2001,16 +2347,45 @@ ${initLines.join('\n')}
2001
2347
  doClassLines.push(` return rows;`);
2002
2348
  doClassLines.push(` }`);
2003
2349
  }
2350
+ // Function-mode lifecycle dispatchers
2351
+ if (alarmHandlers.length > 0) {
2352
+ doClassLines.push(` async alarm(...args) {`);
2353
+ doClassLines.push(` __setDoContext(this);`);
2354
+ for (const h of alarmHandlers) {
2355
+ const handlerVar = `__handler_${toSafeIdentifier(h.fileName)}`;
2356
+ doClassLines.push(` await ${handlerVar}.onAlarm.call(this, ...args);`);
2357
+ }
2358
+ doClassLines.push(` }`);
2359
+ }
2360
+ if (messageHandlers.length > 0) {
2361
+ doClassLines.push(` webSocketMessage(...args) {`);
2362
+ doClassLines.push(` __setDoContext(this);`);
2363
+ for (const h of messageHandlers) {
2364
+ const handlerVar = `__handler_${toSafeIdentifier(h.fileName)}`;
2365
+ doClassLines.push(` ${handlerVar}.onMessage.call(this, ...args);`);
2366
+ }
2367
+ doClassLines.push(` }`);
2368
+ }
2004
2369
  doClassLines.push(`}`);
2005
- // Apply handler methods to prototype
2370
+ // Apply handler methods to prototype (outside class body)
2006
2371
  for (const h of handlers) {
2007
2372
  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}');`);
2373
+ if (h.mode === 'class') {
2374
+ 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)); }; }`);
2375
+ doResolverLines.push(` __registerDoClassBinding(${handlerVar}, '${doEntry.binding}');`);
2376
+ }
2377
+ else {
2378
+ const lifecycle = new Set(['onInit', 'onAlarm', 'onMessage']);
2379
+ for (const fn of h.exportedFunctions) {
2380
+ if (lifecycle.has(fn))
2381
+ continue;
2382
+ doClassLines.push(`${doEntry.className}.prototype[${JSON.stringify(fn)}] = function(...__a){ __setDoContext(this); return ${handlerVar}.${fn}.apply(this, __a.map(__decodeDoArg)); };`);
2383
+ }
2384
+ }
2010
2385
  }
2011
2386
  // Register stub resolver
2012
2387
  if (doEntry.stubId) {
2013
- // Config-driven: e.g. stubId: 'user.orgId' → __u.orgId
2388
+ // Config-driven: e.g. stubId: 'user.orgId' â†' __u.orgId
2014
2389
  const fieldPath = doEntry.stubId.startsWith('user.') ? `__u.${doEntry.stubId.slice(5)}` : doEntry.stubId;
2015
2390
  const checkField = doEntry.stubId.startsWith('user.') ? doEntry.stubId.slice(5) : doEntry.stubId;
2016
2391
  doResolverLines.push(` __registerDoResolver('${doEntry.binding}', async () => {`);
@@ -2020,37 +2395,37 @@ ${initLines.join('\n')}
2020
2395
  doResolverLines.push(` });`);
2021
2396
  }
2022
2397
  else {
2023
- // No stubId config — stub must be obtained manually
2024
- doResolverLines.push(` // No 'stubId' config for ${doEntry.binding} — stub must be obtained manually`);
2398
+ // No stubId config â€" stub must be obtained manually
2399
+ doResolverLines.push(` // No 'stubId' config for ${doEntry.binding} â€" stub must be obtained manually`);
2025
2400
  }
2026
2401
  }
2027
2402
  doImports = doImportLines.join('\n');
2028
- doClassCode = `\n// ── Durable Object Classes (generated) ──────────────────────────\n\n` + doClassLines.join('\n') + '\n';
2403
+ doClassCode = `\n// â"€â"€ Durable Object Classes (generated) â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€\n\n` + doClassLines.join('\n') + '\n';
2029
2404
  doResolverInit = `\nfunction __initDoResolvers() {\n${doResolverLines.join('\n')}\n}\n`;
2030
2405
  }
2031
- return `// Generated by KuratchiJS compiler — do not edit.
2406
+ return `// Generated by KuratchiJS compiler â€" do not edit.
2032
2407
  ${opts.isDev ? '\nglobalThis.__kuratchi_DEV__ = true;\n' : ''}
2033
2408
  ${workerImport}
2034
2409
  ${contextImport}
2035
- ${runtimeImport ? runtimeImport + '\n' : ''}${migrationImports ? migrationImports + '\n' : ''}${authPluginImports ? authPluginImports + '\n' : ''}${doImports ? doImports + '\n' : ''}${opts.serverImports.join('\n')}
2410
+ ${runtimeImport ? runtimeImport + '\n' : ''}${migrationImports ? migrationImports + '\n' : ''}${authPluginImports ? authPluginImports + '\n' : ''}${doImports ? doImports + '\n' : ''}${opts.serverImports.join('\n')}
2036
2411
 
2037
- // ── Assets ──────────────────────────────────────────────────────
2412
+ // â"€â"€ Assets â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
2038
2413
 
2039
2414
  const __assets = {
2040
2415
  ${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
2416
  };
2042
2417
 
2043
- // ── Router ──────────────────────────────────────────────────────
2418
+ // â"€â"€ Router â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
2044
2419
 
2045
- const __staticRoutes = new Map(); // exact path → index (O(1) lookup)
2420
+ const __staticRoutes = new Map(); // exact path â†' index (O(1) lookup)
2046
2421
  const __dynamicRoutes = []; // regex-based routes (params/wildcards)
2047
2422
 
2048
2423
  function __addRoute(pattern, index) {
2049
2424
  if (!pattern.includes(':') && !pattern.includes('*')) {
2050
- // Static route — direct Map lookup, no regex needed
2425
+ // Static route â€" direct Map lookup, no regex needed
2051
2426
  __staticRoutes.set(pattern, index);
2052
2427
  } else {
2053
- // Dynamic route — build regex for param extraction
2428
+ // Dynamic route â€" build regex for param extraction
2054
2429
  const paramNames = [];
2055
2430
  let regexStr = pattern
2056
2431
  .replace(/\\*(\\w+)/g, (_, name) => { paramNames.push(name); return '(?<' + name + '>.+)'; })
@@ -2076,13 +2451,13 @@ function __match(pathname) {
2076
2451
  return null;
2077
2452
  }
2078
2453
 
2079
- // ── Layout ──────────────────────────────────────────────────────
2454
+ // â"€â"€ Layout â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
2080
2455
 
2081
2456
  ${layoutBlock}
2082
2457
 
2083
2458
  ${layoutActionsBlock}
2084
2459
 
2085
- // ── Error pages ─────────────────────────────────────────────────
2460
+ // â"€â"€ Error pages â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
2086
2461
 
2087
2462
  const __errorMessages = {
2088
2463
  400: 'Bad Request',
@@ -2097,7 +2472,7 @@ const __errorMessages = {
2097
2472
  503: 'Service Unavailable',
2098
2473
  };
2099
2474
 
2100
- // Built-in default error page — clean, dark, minimal, centered
2475
+ // Built-in default error page â€" clean, dark, minimal, centered
2101
2476
  function __errorPage(status, detail) {
2102
2477
  const title = __errorMessages[status] || 'Error';
2103
2478
  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 +2494,8 @@ function __error(status, detail) {
2119
2494
  return __errorPage(status, detail);
2120
2495
  }
2121
2496
 
2122
- ${opts.compiledComponents.length > 0 ? '// ── Components ──────────────────────────────────────────────────\n\n' + opts.compiledComponents.join('\n\n') + '\n' : ''}${migrationInit}${authInit}${authPluginInit}${doResolverInit}${doClassCode}
2123
- // ── Route definitions ───────────────────────────────────────────
2497
+ ${opts.compiledComponents.length > 0 ? '// â"€â"€ Components â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€\n\n' + opts.compiledComponents.join('\n\n') + '\n' : ''}${migrationInit}${authInit}${authPluginInit}${doResolverInit}${doClassCode}
2498
+ // â"€â"€ Route definitions â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
2124
2499
 
2125
2500
  const routes = [
2126
2501
  ${opts.compiledRoutes.join(',\n')}
@@ -2128,7 +2503,7 @@ ${opts.compiledRoutes.join(',\n')}
2128
2503
 
2129
2504
  for (let i = 0; i < routes.length; i++) __addRoute(routes[i].pattern, i);
2130
2505
 
2131
- // ── Response helpers ────────────────────────────────────────────
2506
+ // â"€â"€ Response helpers â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
2132
2507
 
2133
2508
  const __defaultSecHeaders = {
2134
2509
  'X-Content-Type-Options': 'nosniff',
@@ -2232,13 +2607,13 @@ async function __runRuntimeError(ctx, error) {
2232
2607
  return null;
2233
2608
  }
2234
2609
 
2235
- // ── Exported Worker entrypoint ──────────────────────────────────
2610
+ // â"€â"€ Exported Worker entrypoint â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€
2236
2611
 
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' : ''}
2612
+ export default class extends WorkerEntrypoint {
2613
+ async fetch(request) {
2614
+ __setRequestContext(this.ctx, request);
2615
+ globalThis.__cloudflare_env__ = __env;
2616
+ ${migrationInit ? ' await __runMigrations();\n' : ''}${authInit ? ' __initAuth(request);\n' : ''}${authPluginInit ? ' __initAuthPlugins();\n' : ''}${doResolverInit ? ' __initDoResolvers();\n' : ''}
2242
2617
  const __runtimeCtx = {
2243
2618
  request,
2244
2619
  env: __env,
@@ -2277,6 +2652,24 @@ ${ac?.hasRateLimit ? '\n // Rate limiting - check before route handlers\n
2277
2652
  __runtimeCtx.params = match.params;
2278
2653
  const route = routes[match.index];
2279
2654
  __setLocal('params', match.params);
2655
+
2656
+ // API route: dispatch to method handler
2657
+ if (route.__api) {
2658
+ const method = request.method;
2659
+ if (method === 'OPTIONS') {
2660
+ const handler = route['OPTIONS'];
2661
+ if (typeof handler === 'function') return __secHeaders(await handler(__runtimeCtx));
2662
+ const allowed = ['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS'].filter(m => typeof route[m] === 'function').join(', ');
2663
+ return __secHeaders(new Response(null, { status: 204, headers: { 'Allow': allowed, 'Access-Control-Allow-Methods': allowed } }));
2664
+ }
2665
+ const handler = route[method];
2666
+ if (typeof handler !== 'function') {
2667
+ const allowed = ['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS'].filter(m => typeof route[m] === 'function').join(', ');
2668
+ return __secHeaders(new Response(JSON.stringify({ error: 'Method Not Allowed' }), { status: 405, headers: { 'content-type': 'application/json', 'Allow': allowed } }));
2669
+ }
2670
+ return __secHeaders(await handler(__runtimeCtx));
2671
+ }
2672
+
2280
2673
  const __qFn = request.headers.get('x-kuratchi-query-fn') || '';
2281
2674
  const __qArgsRaw = request.headers.get('x-kuratchi-query-args') || '[]';
2282
2675
  let __qArgs = [];
@@ -2350,7 +2743,10 @@ ${ac?.hasRateLimit ? '\n // Rate limiting - check before route handlers\n
2350
2743
  const data = (__loaded && typeof __loaded === 'object') ? __loaded : { value: __loaded };
2351
2744
  data.params = match.params;
2352
2745
  data.breadcrumbs = __getLocals().__breadcrumbs ?? [];
2353
- data.__error = (typeof __kuratchi_DEV__ !== 'undefined' && err && err.message) ? err.message : 'Action failed';
2746
+ const __allActions = Object.assign({}, route.actions, __layoutActions || {});
2747
+ Object.keys(__allActions).forEach(function(k) { if (!(k in data)) data[k] = { error: undefined, loading: false, success: false }; });
2748
+ const __errMsg = (err && err.isActionError) ? err.message : (typeof __kuratchi_DEV__ !== 'undefined' && err && err.message) ? err.message : 'Action failed';
2749
+ data[actionName] = { error: __errMsg, loading: false, success: false };
2354
2750
  return ${opts.isLayoutAsync ? 'await ' : ''}__render(route, data);
2355
2751
  }
2356
2752
  // Fetch-based actions return lightweight JSON (no page re-render)
@@ -2373,11 +2769,14 @@ ${ac?.hasRateLimit ? '\n // Rate limiting - check before route handlers\n
2373
2769
  const data = (__loaded && typeof __loaded === 'object') ? __loaded : { value: __loaded };
2374
2770
  data.params = match.params;
2375
2771
  data.breadcrumbs = __getLocals().__breadcrumbs ?? [];
2772
+ const __allActionsGet = Object.assign({}, route.actions, __layoutActions || {});
2773
+ Object.keys(__allActionsGet).forEach(function(k) { if (!(k in data)) data[k] = { error: undefined, loading: false, success: false }; });
2376
2774
  return ${opts.isLayoutAsync ? 'await ' : ''}__render(route, data);
2377
2775
  } catch (err) {
2378
2776
  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' } }));
2777
+ const __pageErrStatus = (err && err.isPageError && err.status) ? err.status : 500;
2778
+ const __errDetail = (err && err.isPageError) ? err.message : (typeof __kuratchi_DEV__ !== 'undefined' && err && err.message) ? err.message : undefined;
2779
+ return __secHeaders(new Response(${opts.isLayoutAsync ? 'await ' : ''}__layout(__error(__pageErrStatus, __errDetail)), { status: __pageErrStatus, headers: { 'content-type': 'text/html; charset=utf-8' } }));
2381
2780
  }
2382
2781
  };
2383
2782