@kuratchi/js 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +78 -0
- package/dist/compiler/index.d.ts +34 -0
- package/dist/compiler/index.js +2200 -0
- package/dist/compiler/parser.d.ts +40 -0
- package/dist/compiler/parser.js +534 -0
- package/dist/compiler/template.d.ts +30 -0
- package/dist/compiler/template.js +625 -0
- package/dist/create.d.ts +7 -0
- package/dist/create.js +876 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +15 -0
- package/dist/runtime/app.d.ts +12 -0
- package/dist/runtime/app.js +118 -0
- package/dist/runtime/config.d.ts +5 -0
- package/dist/runtime/config.js +6 -0
- package/dist/runtime/containers.d.ts +61 -0
- package/dist/runtime/containers.js +127 -0
- package/dist/runtime/context.d.ts +54 -0
- package/dist/runtime/context.js +134 -0
- package/dist/runtime/do.d.ts +81 -0
- package/dist/runtime/do.js +123 -0
- package/dist/runtime/index.d.ts +8 -0
- package/dist/runtime/index.js +8 -0
- package/dist/runtime/router.d.ts +29 -0
- package/dist/runtime/router.js +73 -0
- package/dist/runtime/types.d.ts +207 -0
- package/dist/runtime/types.js +4 -0
- package/package.json +50 -0
|
@@ -0,0 +1,2200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiler — scans a project's routes/ directory, parses .html files,
|
|
3
|
+
* and generates a single Worker entry point.
|
|
4
|
+
*/
|
|
5
|
+
import { parseFile } from './parser.js';
|
|
6
|
+
import { compileTemplate } from './template.js';
|
|
7
|
+
import { filePathToPattern } from '../runtime/router.js';
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import * as crypto from 'node:crypto';
|
|
11
|
+
export { parseFile } from './parser.js';
|
|
12
|
+
export { compileTemplate, generateRenderFunction } from './template.js';
|
|
13
|
+
const FRAMEWORK_PACKAGE_NAME = getFrameworkPackageName();
|
|
14
|
+
const RUNTIME_CONTEXT_IMPORT = `${FRAMEWORK_PACKAGE_NAME}/runtime/context.js`;
|
|
15
|
+
const RUNTIME_DO_IMPORT = `${FRAMEWORK_PACKAGE_NAME}/runtime/do.js`;
|
|
16
|
+
function getFrameworkPackageName() {
|
|
17
|
+
try {
|
|
18
|
+
const raw = fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf-8');
|
|
19
|
+
const parsed = JSON.parse(raw);
|
|
20
|
+
return parsed.name || 'KuratchiJS';
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return 'KuratchiJS';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function compactInlineJs(source) {
|
|
27
|
+
return source
|
|
28
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
29
|
+
.replace(/\n+/g, ' ')
|
|
30
|
+
.replace(/\s{2,}/g, ' ')
|
|
31
|
+
.replace(/\s*([{}();,:])\s*/g, '$1')
|
|
32
|
+
.trim();
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Compile a project's src/routes/ into .kuratchi/routes.js
|
|
36
|
+
*
|
|
37
|
+
* The generated module exports { app } — an object with a fetch() method
|
|
38
|
+
* that handles routing, load functions, form actions, and rendering.
|
|
39
|
+
* The project's src/index.ts imports this and re-exports it as the Worker default.
|
|
40
|
+
*/
|
|
41
|
+
export function compile(options) {
|
|
42
|
+
const { projectDir } = options;
|
|
43
|
+
const srcDir = path.join(projectDir, 'src');
|
|
44
|
+
const routesDir = path.join(srcDir, 'routes');
|
|
45
|
+
if (!fs.existsSync(routesDir)) {
|
|
46
|
+
throw new Error(`Routes directory not found: ${routesDir}`);
|
|
47
|
+
}
|
|
48
|
+
// Discover all .html route files
|
|
49
|
+
const routeFiles = discoverRoutes(routesDir);
|
|
50
|
+
// Component compilation cache — only compile components that are actually imported
|
|
51
|
+
const libDir = path.join(srcDir, 'lib');
|
|
52
|
+
const compiledComponentCache = new Map(); // fileName → compiled function code
|
|
53
|
+
const componentStyleCache = new Map(); // fileName → escaped CSS string (or empty)
|
|
54
|
+
// Tracks which prop names inside a component are used as action={propName}.
|
|
55
|
+
// e.g. db-studio uses action={runQueryAction} → stores 'runQueryAction'.
|
|
56
|
+
// When the route passes runQueryAction={runAdminSqlQuery}, the compiler knows
|
|
57
|
+
// to add 'runAdminSqlQuery' to the route's actionFunctions.
|
|
58
|
+
const componentActionCache = new Map(); // fileName → Set of action prop names
|
|
59
|
+
function compileComponent(fileName) {
|
|
60
|
+
if (compiledComponentCache.has(fileName))
|
|
61
|
+
return compiledComponentCache.get(fileName);
|
|
62
|
+
let filePath;
|
|
63
|
+
let funcName;
|
|
64
|
+
// Package component: "@kuratchi/ui:badge" → resolve from package
|
|
65
|
+
const pkgMatch = fileName.match(/^(@[^:]+):(.+)$/);
|
|
66
|
+
if (pkgMatch) {
|
|
67
|
+
const pkgName = pkgMatch[1]; // e.g. "@kuratchi/ui"
|
|
68
|
+
const componentFile = pkgMatch[2]; // e.g. "badge"
|
|
69
|
+
funcName = '__c_' + componentFile.replace(/[\/\-]/g, '_');
|
|
70
|
+
// Resolve the package's src/lib/ directory
|
|
71
|
+
filePath = resolvePackageComponent(projectDir, pkgName, componentFile);
|
|
72
|
+
if (!filePath || !fs.existsSync(filePath))
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
// Local component: resolve from src/lib/
|
|
77
|
+
funcName = '__c_' + fileName.replace(/[\/\-]/g, '_');
|
|
78
|
+
filePath = path.join(libDir, fileName + '.html');
|
|
79
|
+
if (!fs.existsSync(filePath))
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
// Generate a short scope hash for scoped CSS
|
|
83
|
+
const scopeHash = 'dz-' + crypto.createHash('md5').update(fileName).digest('hex').slice(0, 6);
|
|
84
|
+
const rawSource = fs.readFileSync(filePath, 'utf-8');
|
|
85
|
+
// Use parseFile to properly split the <script> block from the template, and to
|
|
86
|
+
// separate component imports (import X from '@kuratchi/ui/x.html') from regular code.
|
|
87
|
+
// This prevents import lines from being inlined verbatim in the compiled function body.
|
|
88
|
+
const compParsed = parseFile(rawSource);
|
|
89
|
+
// propsCode = script body with all import lines stripped out
|
|
90
|
+
const propsCode = compParsed.script
|
|
91
|
+
? compParsed.script
|
|
92
|
+
.replace(/^\s*import[\s\S]*?from\s+['"][^'"]+['"]\s*;?/gm, '')
|
|
93
|
+
.trim()
|
|
94
|
+
: '';
|
|
95
|
+
// template source (parseFile already removes the <script> block)
|
|
96
|
+
let source = compParsed.template;
|
|
97
|
+
// Extract optional <style> block — CSS is scoped and injected once per route at compile time
|
|
98
|
+
let styleBlock = '';
|
|
99
|
+
const styleMatch = source.match(/<style[\s>][\s\S]*?<\/style>/i);
|
|
100
|
+
if (styleMatch) {
|
|
101
|
+
styleBlock = styleMatch[0];
|
|
102
|
+
source = source.replace(styleMatch[0], '').trim();
|
|
103
|
+
}
|
|
104
|
+
// Scope the CSS: prefix every selector with .dz-{hash}
|
|
105
|
+
let scopedStyle = '';
|
|
106
|
+
if (styleBlock) {
|
|
107
|
+
// Extract the CSS content between <style> and </style>
|
|
108
|
+
const cssContent = styleBlock.replace(/<style[^>]*>/i, '').replace(/<\/style>/i, '').trim();
|
|
109
|
+
// Prefix each rule's selector(s) with the scope class
|
|
110
|
+
const scopedCSS = cssContent.replace(/([^{}]+)\{/g, (match, selectors) => {
|
|
111
|
+
const scoped = selectors
|
|
112
|
+
.split(',')
|
|
113
|
+
.map((s) => `.${scopeHash} ${s.trim()}`)
|
|
114
|
+
.join(', ');
|
|
115
|
+
return scoped + ' {';
|
|
116
|
+
});
|
|
117
|
+
scopedStyle = `<style>${scopedCSS}</style>`;
|
|
118
|
+
}
|
|
119
|
+
// Store escaped scoped CSS separately for compile-time injection into routes
|
|
120
|
+
const escapedStyle = scopedStyle
|
|
121
|
+
? scopedStyle.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')
|
|
122
|
+
: '';
|
|
123
|
+
componentStyleCache.set(fileName, escapedStyle);
|
|
124
|
+
// Replace <slot></slot> and <slot /> with children output
|
|
125
|
+
source = source.replace(/<slot\s*><\/slot>/g, '{=html props.children || ""}');
|
|
126
|
+
source = source.replace(/<slot\s*\/>/g, '{=html props.children || ""}');
|
|
127
|
+
// Build a sub-component map from the component's own component imports so that
|
|
128
|
+
// <Alert>, <Badge>, <Dialog>, etc. get expanded instead of emitted as raw tags.
|
|
129
|
+
const subComponentNames = new Map();
|
|
130
|
+
for (const [subPascal, subFileName] of Object.entries(compParsed.componentImports)) {
|
|
131
|
+
compileComponent(subFileName); // compile on first use (cached)
|
|
132
|
+
subComponentNames.set(subPascal, subFileName);
|
|
133
|
+
// Collect sub-component styles so they're available when the route gathers styles
|
|
134
|
+
const subStyle = componentStyleCache.get(subFileName);
|
|
135
|
+
if (subStyle) {
|
|
136
|
+
const existing = componentStyleCache.get(fileName) || '';
|
|
137
|
+
if (!existing.includes(subStyle)) {
|
|
138
|
+
componentStyleCache.set(fileName, existing + subStyle);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Scan the component template for action={propName} uses.
|
|
143
|
+
// These prop names are "action props" — when the route passes actionProp={routeFn},
|
|
144
|
+
// the compiler knows to add routeFn to the route's actionFunctions so it ends up
|
|
145
|
+
// in the route's actions map and can be dispatched at runtime.
|
|
146
|
+
const actionPropNames = new Set();
|
|
147
|
+
for (const match of source.matchAll(/\baction=\{([A-Za-z_$][\w$]*)\}/g)) {
|
|
148
|
+
actionPropNames.add(match[1]);
|
|
149
|
+
}
|
|
150
|
+
componentActionCache.set(fileName, actionPropNames);
|
|
151
|
+
const body = compileTemplate(source, subComponentNames, undefined, undefined);
|
|
152
|
+
// Wrap component output in a scoped div
|
|
153
|
+
const scopeOpen = `__html += '<div class="${scopeHash}">';`;
|
|
154
|
+
const scopeClose = `__html += '</div>';`;
|
|
155
|
+
// Insert scope open after 'let __html = "";' (first line of body) and scope close at end
|
|
156
|
+
const bodyLines = body.split('\n');
|
|
157
|
+
const scopedBody = [bodyLines[0], scopeOpen, ...bodyLines.slice(1), scopeClose].join('\n');
|
|
158
|
+
const fnBody = propsCode ? `${propsCode}\n ${scopedBody}` : scopedBody;
|
|
159
|
+
const compiled = `function ${funcName}(props, __esc) {\n ${fnBody}\n return __html;\n}`;
|
|
160
|
+
compiledComponentCache.set(fileName, compiled);
|
|
161
|
+
return compiled;
|
|
162
|
+
}
|
|
163
|
+
// App layout: src/routes/layout.html (convention — wraps all routes automatically)
|
|
164
|
+
const layoutFile = path.join(routesDir, 'layout.html');
|
|
165
|
+
let compiledLayout = null;
|
|
166
|
+
const layoutComponentNames = new Map();
|
|
167
|
+
if (fs.existsSync(layoutFile)) {
|
|
168
|
+
let source = fs.readFileSync(layoutFile, 'utf-8');
|
|
169
|
+
// Inject UI theme CSS if configured in kuratchi.config.ts
|
|
170
|
+
const themeCSS = readUiTheme(projectDir);
|
|
171
|
+
if (themeCSS) {
|
|
172
|
+
source = source.replace('</head>', `<style>${themeCSS}</style>\n</head>`);
|
|
173
|
+
}
|
|
174
|
+
// Inject @view-transition CSS for cross-document transitions (MPA)
|
|
175
|
+
const viewTransitionCSS = `<style>@view-transition { navigation: auto; }</style>`;
|
|
176
|
+
source = source.replace('</head>', viewTransitionCSS + '\n</head>');
|
|
177
|
+
// Inject progressive client bridge:
|
|
178
|
+
// - server actions bound via onX={serverAction(...)} -> [data-action][data-action-event]
|
|
179
|
+
// - declarative confirm="..."
|
|
180
|
+
// - declarative checkbox groups: data-select-all / data-select-item
|
|
181
|
+
const bridgeSource = `(function(){
|
|
182
|
+
function by(sel, root){ return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }
|
|
183
|
+
var __refreshSeq = Object.create(null);
|
|
184
|
+
function syncGroup(group){
|
|
185
|
+
var items = by('[data-select-item="' + group + '"]');
|
|
186
|
+
var masters = by('[data-select-all="' + group + '"]');
|
|
187
|
+
if(!items.length || !masters.length) return;
|
|
188
|
+
var all = items.every(function(i){ return !!i.checked; });
|
|
189
|
+
var any = items.some(function(i){ return !!i.checked; });
|
|
190
|
+
masters.forEach(function(m){ m.checked = all; m.indeterminate = any && !all; });
|
|
191
|
+
}
|
|
192
|
+
function inferQueryKey(getName, argsRaw){
|
|
193
|
+
if(!getName) return '';
|
|
194
|
+
return 'query:' + String(getName) + '|' + (argsRaw || '[]');
|
|
195
|
+
}
|
|
196
|
+
function blockKey(el){
|
|
197
|
+
if(!el || !el.getAttribute) return '';
|
|
198
|
+
var explicit = el.getAttribute('data-key');
|
|
199
|
+
if(explicit) return 'key:' + explicit;
|
|
200
|
+
var inferred = inferQueryKey(el.getAttribute('data-get'), el.getAttribute('data-get-args'));
|
|
201
|
+
if(inferred) return inferred;
|
|
202
|
+
var asName = el.getAttribute('data-as');
|
|
203
|
+
if(asName) return 'as:' + asName;
|
|
204
|
+
return '';
|
|
205
|
+
}
|
|
206
|
+
function escHtml(v){
|
|
207
|
+
return String(v || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
208
|
+
}
|
|
209
|
+
function setBlocksLoading(blocks){
|
|
210
|
+
blocks.forEach(function(el){
|
|
211
|
+
el.setAttribute('aria-busy','true');
|
|
212
|
+
el.setAttribute('data-kuratchi-loading','1');
|
|
213
|
+
var text = el.getAttribute('data-loading-text');
|
|
214
|
+
if(text && !el.hasAttribute('data-as')){ el.innerHTML = '<p>' + escHtml(text) + '</p>'; return; }
|
|
215
|
+
el.style.opacity = '0.6';
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
function clearBlocksLoading(blocks){
|
|
219
|
+
blocks.forEach(function(el){
|
|
220
|
+
el.removeAttribute('aria-busy');
|
|
221
|
+
el.removeAttribute('data-kuratchi-loading');
|
|
222
|
+
el.style.opacity = '';
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
function replaceBlocksWithKey(key){
|
|
226
|
+
if(!key || typeof DOMParser === 'undefined'){ location.reload(); return Promise.resolve(); }
|
|
227
|
+
var oldBlocks = by('[data-get]').filter(function(el){ return blockKey(el) === key; });
|
|
228
|
+
if(!oldBlocks.length){ location.reload(); return Promise.resolve(); }
|
|
229
|
+
var first = oldBlocks[0];
|
|
230
|
+
var qFn = first ? (first.getAttribute('data-get') || '') : '';
|
|
231
|
+
var qArgs = first ? String(first.getAttribute('data-get-args') || '[]') : '[]';
|
|
232
|
+
var seq = (__refreshSeq[key] || 0) + 1;
|
|
233
|
+
__refreshSeq[key] = seq;
|
|
234
|
+
setBlocksLoading(oldBlocks);
|
|
235
|
+
var headers = { 'x-kuratchi-refresh': '1' };
|
|
236
|
+
if(qFn){ headers['x-kuratchi-query-fn'] = String(qFn); headers['x-kuratchi-query-args'] = qArgs; }
|
|
237
|
+
return fetch(location.pathname + location.search, { headers: headers })
|
|
238
|
+
.then(function(r){ if(!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
|
|
239
|
+
.then(function(html){
|
|
240
|
+
if(__refreshSeq[key] !== seq) return;
|
|
241
|
+
var doc = new DOMParser().parseFromString(html, 'text/html');
|
|
242
|
+
var newBlocks = by('[data-get]', doc).filter(function(el){ return blockKey(el) === key; });
|
|
243
|
+
if(!oldBlocks.length || !newBlocks.length){ location.reload(); return; }
|
|
244
|
+
for(var i=0;i<oldBlocks.length;i++){ if(newBlocks[i]) oldBlocks[i].outerHTML = newBlocks[i].outerHTML; }
|
|
245
|
+
by('[data-select-all]').forEach(function(m){ var g=m.getAttribute('data-select-all'); if(g) syncGroup(g); });
|
|
246
|
+
})
|
|
247
|
+
.catch(function(){
|
|
248
|
+
if(__refreshSeq[key] === seq) clearBlocksLoading(oldBlocks);
|
|
249
|
+
location.reload();
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
function replaceBlocksByDescriptor(fnName, argsRaw){
|
|
253
|
+
if(!fnName || typeof DOMParser === 'undefined'){ location.reload(); return Promise.resolve(); }
|
|
254
|
+
var normalizedArgs = String(argsRaw || '[]');
|
|
255
|
+
var oldBlocks = by('[data-get]').filter(function(el){
|
|
256
|
+
return (el.getAttribute('data-get') || '') === String(fnName) &&
|
|
257
|
+
String(el.getAttribute('data-get-args') || '[]') === normalizedArgs;
|
|
258
|
+
});
|
|
259
|
+
if(!oldBlocks.length){ location.reload(); return Promise.resolve(); }
|
|
260
|
+
var key = 'fn:' + String(fnName) + '|' + normalizedArgs;
|
|
261
|
+
var seq = (__refreshSeq[key] || 0) + 1;
|
|
262
|
+
__refreshSeq[key] = seq;
|
|
263
|
+
setBlocksLoading(oldBlocks);
|
|
264
|
+
return fetch(location.pathname + location.search, {
|
|
265
|
+
headers: {
|
|
266
|
+
'x-kuratchi-refresh': '1',
|
|
267
|
+
'x-kuratchi-query-fn': String(fnName),
|
|
268
|
+
'x-kuratchi-query-args': normalizedArgs,
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
.then(function(r){ if(!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
|
|
272
|
+
.then(function(html){
|
|
273
|
+
if(__refreshSeq[key] !== seq) return;
|
|
274
|
+
var doc = new DOMParser().parseFromString(html, 'text/html');
|
|
275
|
+
var newBlocks = by('[data-get]', doc).filter(function(el){
|
|
276
|
+
return (el.getAttribute('data-get') || '') === String(fnName) &&
|
|
277
|
+
String(el.getAttribute('data-get-args') || '[]') === normalizedArgs;
|
|
278
|
+
});
|
|
279
|
+
if(!newBlocks.length){ location.reload(); return; }
|
|
280
|
+
for(var i=0;i<oldBlocks.length;i++){ if(newBlocks[i]) oldBlocks[i].outerHTML = newBlocks[i].outerHTML; }
|
|
281
|
+
by('[data-select-all]').forEach(function(m){ var g=m.getAttribute('data-select-all'); if(g) syncGroup(g); });
|
|
282
|
+
})
|
|
283
|
+
.catch(function(){
|
|
284
|
+
if(__refreshSeq[key] === seq) clearBlocksLoading(oldBlocks);
|
|
285
|
+
location.reload();
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
function refreshByDescriptor(fnName, argsRaw){
|
|
289
|
+
if(!fnName) { location.reload(); return Promise.resolve(); }
|
|
290
|
+
return replaceBlocksByDescriptor(fnName, argsRaw || '[]');
|
|
291
|
+
}
|
|
292
|
+
function refreshNearest(el){
|
|
293
|
+
var host = el && el.closest ? el.closest('[data-get]') : null;
|
|
294
|
+
if(!host){ location.reload(); return Promise.resolve(); }
|
|
295
|
+
return replaceBlocksWithKey(blockKey(host));
|
|
296
|
+
}
|
|
297
|
+
function refreshTargets(raw){
|
|
298
|
+
if(!raw){ location.reload(); return Promise.resolve(); }
|
|
299
|
+
var keys = String(raw).split(',').map(function(v){ return v.trim(); }).filter(Boolean);
|
|
300
|
+
if(!keys.length){ location.reload(); return Promise.resolve(); }
|
|
301
|
+
return Promise.all(keys.map(function(k){ return replaceBlocksWithKey('key:' + k); })).then(function(){});
|
|
302
|
+
}
|
|
303
|
+
function act(e){
|
|
304
|
+
if(e.type === 'click'){
|
|
305
|
+
var g = e.target && e.target.closest ? e.target.closest('[data-get]') : null;
|
|
306
|
+
if(g && !g.hasAttribute('data-as') && !g.hasAttribute('data-action')){
|
|
307
|
+
var getUrl = g.getAttribute('data-get');
|
|
308
|
+
if(getUrl){
|
|
309
|
+
e.preventDefault();
|
|
310
|
+
location.assign(getUrl);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
var r = e.target && e.target.closest ? e.target.closest('[data-refresh]') : null;
|
|
315
|
+
if(r && !r.hasAttribute('data-action')){
|
|
316
|
+
e.preventDefault();
|
|
317
|
+
var rf = r.getAttribute('data-refresh');
|
|
318
|
+
var ra = r.getAttribute('data-refresh-args');
|
|
319
|
+
if(ra !== null){ refreshByDescriptor(rf, ra || '[]'); return; }
|
|
320
|
+
if(rf && rf.trim()){ refreshTargets(rf); return; }
|
|
321
|
+
refreshNearest(r);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
var sel = '[data-action][data-action-event="' + e.type + '"]';
|
|
326
|
+
var b = e.target && e.target.closest ? e.target.closest(sel) : null;
|
|
327
|
+
if(!b) return;
|
|
328
|
+
e.preventDefault();
|
|
329
|
+
var fd = new FormData();
|
|
330
|
+
fd.append('_action', b.getAttribute('data-action') || '');
|
|
331
|
+
fd.append('_args', b.getAttribute('data-args') || '[]');
|
|
332
|
+
var m = b.getAttribute('data-action-method');
|
|
333
|
+
if(m) fd.append('_method', String(m).toUpperCase());
|
|
334
|
+
fetch(location.pathname, { method: 'POST', body: fd })
|
|
335
|
+
.then(function(r){
|
|
336
|
+
if(!r.ok){
|
|
337
|
+
return r.json().then(function(j){ throw new Error((j && j.error) || ('HTTP ' + r.status)); }).catch(function(){ throw new Error('HTTP ' + r.status); });
|
|
338
|
+
}
|
|
339
|
+
return r.json();
|
|
340
|
+
})
|
|
341
|
+
.then(function(){
|
|
342
|
+
if(!b.hasAttribute('data-refresh')) return;
|
|
343
|
+
var refreshFn = b.getAttribute('data-refresh');
|
|
344
|
+
var refreshArgs = b.getAttribute('data-refresh-args');
|
|
345
|
+
if(refreshArgs !== null){ return refreshByDescriptor(refreshFn, refreshArgs || '[]'); }
|
|
346
|
+
if(refreshFn && refreshFn.trim()){ return refreshTargets(refreshFn); }
|
|
347
|
+
return refreshNearest(b);
|
|
348
|
+
})
|
|
349
|
+
.catch(function(err){ console.error('[kuratchi] client action error:', err); });
|
|
350
|
+
}
|
|
351
|
+
['click','change','input','focus','blur'].forEach(function(ev){ document.addEventListener(ev, act, true); });
|
|
352
|
+
function autoLoadQueries(){
|
|
353
|
+
var seen = Object.create(null);
|
|
354
|
+
by('[data-get][data-as]').forEach(function(el){
|
|
355
|
+
var fn = el.getAttribute('data-get');
|
|
356
|
+
if(!fn) return;
|
|
357
|
+
var args = String(el.getAttribute('data-get-args') || '[]');
|
|
358
|
+
var key = String(fn) + '|' + args;
|
|
359
|
+
if(seen[key]) return;
|
|
360
|
+
seen[key] = true;
|
|
361
|
+
replaceBlocksByDescriptor(fn, args);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
if(document.readyState === 'loading'){
|
|
365
|
+
document.addEventListener('DOMContentLoaded', autoLoadQueries, { once: true });
|
|
366
|
+
} else {
|
|
367
|
+
autoLoadQueries();
|
|
368
|
+
}
|
|
369
|
+
document.addEventListener('click', function(e){
|
|
370
|
+
var b = e.target && e.target.closest ? e.target.closest('[command="fill-dialog"]') : null;
|
|
371
|
+
if(!b) return;
|
|
372
|
+
var targetId = b.getAttribute('commandfor');
|
|
373
|
+
if(!targetId) return;
|
|
374
|
+
var dialog = document.getElementById(targetId);
|
|
375
|
+
if(!dialog) return;
|
|
376
|
+
var raw = b.getAttribute('data-dialog-data');
|
|
377
|
+
if(!raw) return;
|
|
378
|
+
var data;
|
|
379
|
+
try { data = JSON.parse(raw); } catch(_err) { return; }
|
|
380
|
+
Object.keys(data).forEach(function(k){
|
|
381
|
+
var inp = dialog.querySelector('[name="col_' + k + '"]');
|
|
382
|
+
if(inp){
|
|
383
|
+
inp.value = data[k] === null || data[k] === undefined ? '' : String(data[k]);
|
|
384
|
+
inp.placeholder = data[k] === null || data[k] === undefined ? 'NULL' : '';
|
|
385
|
+
}
|
|
386
|
+
var hidden = dialog.querySelector('#dialog-field-' + k);
|
|
387
|
+
if(hidden){
|
|
388
|
+
hidden.value = data[k] === null || data[k] === undefined ? '' : String(data[k]);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
var rowidInp = dialog.querySelector('[name="rowid"]');
|
|
392
|
+
if(rowidInp && data.rowid !== undefined) rowidInp.value = String(data.rowid);
|
|
393
|
+
if(typeof dialog.showModal === 'function') dialog.showModal();
|
|
394
|
+
}, true);
|
|
395
|
+
(function initPoll(){
|
|
396
|
+
var prev = {};
|
|
397
|
+
function bindPollEl(el){
|
|
398
|
+
if(!el || !el.getAttribute) return;
|
|
399
|
+
if(el.getAttribute('data-kuratchi-poll-bound') === '1') return;
|
|
400
|
+
var fn = el.getAttribute('data-poll');
|
|
401
|
+
if(!fn) return;
|
|
402
|
+
el.setAttribute('data-kuratchi-poll-bound', '1');
|
|
403
|
+
var args = el.getAttribute('data-poll-args') || '[]';
|
|
404
|
+
var iv = parseInt(el.getAttribute('data-interval') || '', 10) || 3000;
|
|
405
|
+
var key = String(fn) + args;
|
|
406
|
+
if(!(key in prev)) prev[key] = null;
|
|
407
|
+
(function tick(){
|
|
408
|
+
setTimeout(function(){
|
|
409
|
+
fetch(location.pathname + '?_rpc=' + encodeURIComponent(String(fn)) + '&_args=' + encodeURIComponent(args), { headers: { 'x-kuratchi-rpc': '1' } })
|
|
410
|
+
.then(function(r){ return r.json(); })
|
|
411
|
+
.then(function(j){
|
|
412
|
+
if(j && j.ok){
|
|
413
|
+
var s = JSON.stringify(j.data);
|
|
414
|
+
if(prev[key] !== null && prev[key] !== s){ location.reload(); return; }
|
|
415
|
+
prev[key] = s;
|
|
416
|
+
}
|
|
417
|
+
tick();
|
|
418
|
+
})
|
|
419
|
+
.catch(function(){ tick(); });
|
|
420
|
+
}, iv);
|
|
421
|
+
})();
|
|
422
|
+
}
|
|
423
|
+
function scan(){
|
|
424
|
+
by('[data-poll]').forEach(bindPollEl);
|
|
425
|
+
}
|
|
426
|
+
scan();
|
|
427
|
+
setInterval(scan, 500);
|
|
428
|
+
})();
|
|
429
|
+
function confirmClick(e){
|
|
430
|
+
var el = e.target && e.target.closest ? e.target.closest('[confirm]') : null;
|
|
431
|
+
if(!el) return;
|
|
432
|
+
var msg = el.getAttribute('confirm');
|
|
433
|
+
if(!msg) return;
|
|
434
|
+
if(!window.confirm(msg)){ e.preventDefault(); e.stopPropagation(); }
|
|
435
|
+
}
|
|
436
|
+
document.addEventListener('click', confirmClick, true);
|
|
437
|
+
document.addEventListener('submit', function(e){
|
|
438
|
+
var f = e.target && e.target.matches && e.target.matches('form[confirm]') ? e.target : null;
|
|
439
|
+
if(!f) return;
|
|
440
|
+
var msg = f.getAttribute('confirm');
|
|
441
|
+
if(!msg) return;
|
|
442
|
+
if(!window.confirm(msg)){ e.preventDefault(); e.stopPropagation(); }
|
|
443
|
+
}, true);
|
|
444
|
+
document.addEventListener('change', function(e){
|
|
445
|
+
var t = e.target;
|
|
446
|
+
if(!t || !t.getAttribute) return;
|
|
447
|
+
var gAll = t.getAttribute('data-select-all');
|
|
448
|
+
if(gAll){
|
|
449
|
+
by('[data-select-item="' + gAll + '"]').forEach(function(i){ i.checked = !!t.checked; });
|
|
450
|
+
syncGroup(gAll);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
var gItem = t.getAttribute('data-select-item');
|
|
454
|
+
if(gItem) syncGroup(gItem);
|
|
455
|
+
}, true);
|
|
456
|
+
by('[data-select-all]').forEach(function(m){ var g = m.getAttribute('data-select-all'); if(g) syncGroup(g); });
|
|
457
|
+
})();`;
|
|
458
|
+
const actionScript = `<script>${options.isDev ? bridgeSource : compactInlineJs(bridgeSource)}</script>`;
|
|
459
|
+
source = source.replace('</body>', actionScript + '\n</body>');
|
|
460
|
+
// Parse layout for <script> block (component imports + data vars)
|
|
461
|
+
const layoutParsed = parseFile(source);
|
|
462
|
+
const hasLayoutScript = layoutParsed.script && (Object.keys(layoutParsed.componentImports).length > 0 || layoutParsed.hasLoad);
|
|
463
|
+
if (hasLayoutScript) {
|
|
464
|
+
// Dynamic layout — has component imports and/or data declarations
|
|
465
|
+
// Compile component imports from layout
|
|
466
|
+
for (const [pascalName, fileName] of Object.entries(layoutParsed.componentImports)) {
|
|
467
|
+
compileComponent(fileName);
|
|
468
|
+
layoutComponentNames.set(pascalName, fileName);
|
|
469
|
+
}
|
|
470
|
+
// Replace <slot></slot> with content parameter injection
|
|
471
|
+
let layoutTemplate = layoutParsed.template.replace(/<slot\s*><\/slot>/g, '{=html __content}');
|
|
472
|
+
layoutTemplate = layoutTemplate.replace(/<slot\s*\/>/g, '{=html __content}');
|
|
473
|
+
// Build layout action names so action={fn} works in layouts
|
|
474
|
+
const layoutActionNames = new Set(layoutParsed.actionFunctions);
|
|
475
|
+
// Compile the layout template with component + action support
|
|
476
|
+
const layoutRenderBody = compileTemplate(layoutTemplate, layoutComponentNames, layoutActionNames);
|
|
477
|
+
// Collect component CSS for layout
|
|
478
|
+
const layoutComponentStyles = [];
|
|
479
|
+
for (const fileName of layoutComponentNames.values()) {
|
|
480
|
+
const css = componentStyleCache.get(fileName);
|
|
481
|
+
if (css)
|
|
482
|
+
layoutComponentStyles.push(css);
|
|
483
|
+
}
|
|
484
|
+
// Inject component CSS after 'let __html = "";'
|
|
485
|
+
let finalLayoutBody = layoutRenderBody;
|
|
486
|
+
if (layoutComponentStyles.length > 0) {
|
|
487
|
+
const lines = layoutRenderBody.split('\n');
|
|
488
|
+
const styleLines = layoutComponentStyles.map(css => `__html += \`${css}\\n\`;`);
|
|
489
|
+
finalLayoutBody = [lines[0], ...styleLines, ...lines.slice(1)].join('\n');
|
|
490
|
+
}
|
|
491
|
+
// Build the layout script body (data vars, etc.)
|
|
492
|
+
let layoutScriptBody = layoutParsed.script.replace(/^\s*import[\s\S]*?from\s+['"][^'"]+['"]\s*;?/gm, '').trim();
|
|
493
|
+
compiledLayout = `function __layout(__content) {
|
|
494
|
+
const __esc = (v) => { if (v == null) return ''; return String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); };
|
|
495
|
+
${layoutScriptBody ? layoutScriptBody + '\n ' : ''}${finalLayoutBody}
|
|
496
|
+
return __html;
|
|
497
|
+
}`;
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
// Static layout — no components, use fast string split (original behavior)
|
|
501
|
+
const slotMarker = '<slot></slot>';
|
|
502
|
+
const slotIdx = source.indexOf(slotMarker);
|
|
503
|
+
if (slotIdx === -1) {
|
|
504
|
+
throw new Error('layout.html must contain <slot></slot>');
|
|
505
|
+
}
|
|
506
|
+
const escLayout = (s) => s.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, () => '\\$');
|
|
507
|
+
const before = escLayout(source.slice(0, slotIdx));
|
|
508
|
+
const after = escLayout(source.slice(slotIdx + slotMarker.length));
|
|
509
|
+
compiledLayout = `const __layoutBefore = \`${before}\`;\nconst __layoutAfter = \`${after}\`;\nfunction __layout(content) {\n return __layoutBefore + content + __layoutAfter;\n}`;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// Custom error pages: src/routes/NNN.html (e.g. 404.html, 500.html, 401.html, 403.html)
|
|
513
|
+
// Only compiled if the user explicitly creates them — otherwise the framework's built-in default is used
|
|
514
|
+
const compiledErrorPages = new Map();
|
|
515
|
+
for (const file of fs.readdirSync(routesDir)) {
|
|
516
|
+
const match = file.match(/^(\d{3})\.html$/);
|
|
517
|
+
if (!match)
|
|
518
|
+
continue;
|
|
519
|
+
const status = parseInt(match[1], 10);
|
|
520
|
+
const source = fs.readFileSync(path.join(routesDir, file), 'utf-8');
|
|
521
|
+
const body = compileTemplate(source);
|
|
522
|
+
// 500.html receives `error` as a variable; others don't need it
|
|
523
|
+
compiledErrorPages.set(status, `function __error_${status}(error) {\n ${body}\n return __html;\n}`);
|
|
524
|
+
}
|
|
525
|
+
// Read kuratchi.config.ts at build time to discover ORM database configs
|
|
526
|
+
const ormDatabases = readOrmConfig(projectDir);
|
|
527
|
+
// Read auth config from kuratchi.config.ts
|
|
528
|
+
const authConfig = readAuthConfig(projectDir);
|
|
529
|
+
// Read Durable Object config and discover handler files
|
|
530
|
+
const doConfig = readDoConfig(projectDir);
|
|
531
|
+
const doHandlers = doConfig.length > 0
|
|
532
|
+
? discoverDoHandlers(srcDir, doConfig, ormDatabases)
|
|
533
|
+
: [];
|
|
534
|
+
// Generate handler proxy modules in .kuratchi/do/ (must happen BEFORE route processing
|
|
535
|
+
// so that $durable-objects/X imports can be redirected to the generated proxies)
|
|
536
|
+
const doProxyDir = path.join(projectDir, '.kuratchi', 'do');
|
|
537
|
+
const doHandlerProxyPaths = new Map();
|
|
538
|
+
const registerDoProxyPath = (sourceAbsNoExt, proxyAbsNoExt) => {
|
|
539
|
+
doHandlerProxyPaths.set(sourceAbsNoExt.replace(/\\/g, '/'), proxyAbsNoExt.replace(/\\/g, '/'));
|
|
540
|
+
};
|
|
541
|
+
if (doHandlers.length > 0) {
|
|
542
|
+
if (!fs.existsSync(doProxyDir))
|
|
543
|
+
fs.mkdirSync(doProxyDir, { recursive: true });
|
|
544
|
+
for (const handler of doHandlers) {
|
|
545
|
+
const proxyCode = generateHandlerProxy(handler, projectDir);
|
|
546
|
+
const proxyFile = path.join(doProxyDir, handler.fileName + '.js');
|
|
547
|
+
const proxyFileDir = path.dirname(proxyFile);
|
|
548
|
+
if (!fs.existsSync(proxyFileDir))
|
|
549
|
+
fs.mkdirSync(proxyFileDir, { recursive: true });
|
|
550
|
+
writeIfChanged(proxyFile, proxyCode);
|
|
551
|
+
const handlerAbsNoExt = handler.absPath.replace(/\\/g, '/').replace(/\.ts$/, '');
|
|
552
|
+
const proxyAbsNoExt = proxyFile.replace(/\\/g, '/').replace(/\.js$/, '');
|
|
553
|
+
registerDoProxyPath(handlerAbsNoExt, proxyAbsNoExt);
|
|
554
|
+
// Backward-compatible alias for '.do' suffix.
|
|
555
|
+
registerDoProxyPath(handlerAbsNoExt.replace(/\.do$/, ''), proxyAbsNoExt.replace(/\.do$/, ''));
|
|
556
|
+
// Backward-compatible alias for `$durable-objects/<name>` imports.
|
|
557
|
+
registerDoProxyPath(path.join(srcDir, 'durable-objects', handler.fileName).replace(/\\/g, '/'), proxyAbsNoExt);
|
|
558
|
+
registerDoProxyPath(path.join(srcDir, 'durable-objects', handler.fileName.replace(/\.do$/, '')).replace(/\\/g, '/'), proxyAbsNoExt.replace(/\.do$/, ''));
|
|
559
|
+
if (handler.fileName.endsWith('.do')) {
|
|
560
|
+
const aliasFileName = handler.fileName.slice(0, -3);
|
|
561
|
+
const aliasProxyFile = path.join(doProxyDir, aliasFileName + '.js');
|
|
562
|
+
const aliasCode = `// Auto-generated alias for .do handler\nexport * from './${handler.fileName}.js';\n`;
|
|
563
|
+
const aliasProxyDir = path.dirname(aliasProxyFile);
|
|
564
|
+
if (!fs.existsSync(aliasProxyDir))
|
|
565
|
+
fs.mkdirSync(aliasProxyDir, { recursive: true });
|
|
566
|
+
writeIfChanged(aliasProxyFile, aliasCode);
|
|
567
|
+
registerDoProxyPath(handlerAbsNoExt.replace(/\.do$/, ''), aliasProxyFile.replace(/\\/g, '/').replace(/\.js$/, ''));
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
// Parse and compile each route
|
|
572
|
+
const compiledRoutes = [];
|
|
573
|
+
const allImports = [];
|
|
574
|
+
let moduleCounter = 0;
|
|
575
|
+
// Layout server import resolution — resolve non-component imports to module IDs
|
|
576
|
+
let isLayoutAsync = false;
|
|
577
|
+
let compiledLayoutActions = null;
|
|
578
|
+
if (compiledLayout && fs.existsSync(path.join(routesDir, 'layout.html'))) {
|
|
579
|
+
const layoutSource = fs.readFileSync(path.join(routesDir, 'layout.html'), 'utf-8');
|
|
580
|
+
const layoutParsedForImports = parseFile(layoutSource);
|
|
581
|
+
if (layoutParsedForImports.serverImports.length > 0) {
|
|
582
|
+
const layoutFileDir = routesDir;
|
|
583
|
+
const outFileDir = path.join(projectDir, '.kuratchi');
|
|
584
|
+
const layoutFnToModule = {};
|
|
585
|
+
for (const imp of layoutParsedForImports.serverImports) {
|
|
586
|
+
const pathMatch = imp.match(/from\s+['"]([^'"]+)['"]/);
|
|
587
|
+
if (!pathMatch)
|
|
588
|
+
continue;
|
|
589
|
+
const origPath = pathMatch[1];
|
|
590
|
+
const isBareModule = !origPath.startsWith('.') && !origPath.startsWith('/') && !origPath.startsWith('$');
|
|
591
|
+
let importPath;
|
|
592
|
+
if (isBareModule) {
|
|
593
|
+
importPath = origPath;
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
let absImport;
|
|
597
|
+
if (origPath.startsWith('$')) {
|
|
598
|
+
const slashIdx = origPath.indexOf('/');
|
|
599
|
+
const folder = origPath.slice(1, slashIdx);
|
|
600
|
+
const rest = origPath.slice(slashIdx + 1);
|
|
601
|
+
absImport = path.join(srcDir, folder, rest);
|
|
602
|
+
// Redirect DO handler imports to generated proxy modules
|
|
603
|
+
const doProxyPath = doHandlerProxyPaths.get(absImport.replace(/\\/g, '/'));
|
|
604
|
+
if (doProxyPath) {
|
|
605
|
+
absImport = doProxyPath;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
absImport = path.resolve(layoutFileDir, origPath);
|
|
610
|
+
}
|
|
611
|
+
let relPath = path.relative(outFileDir, absImport).replace(/\\/g, '/');
|
|
612
|
+
if (!relPath.startsWith('.'))
|
|
613
|
+
relPath = './' + relPath;
|
|
614
|
+
let resolvedExt = '';
|
|
615
|
+
for (const ext of ['.ts', '.js', '/index.ts', '/index.js']) {
|
|
616
|
+
if (fs.existsSync(absImport + ext)) {
|
|
617
|
+
resolvedExt = ext;
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
importPath = relPath + resolvedExt;
|
|
622
|
+
}
|
|
623
|
+
const moduleId = `__m${moduleCounter++}`;
|
|
624
|
+
allImports.push(`import * as ${moduleId} from '${importPath}';`);
|
|
625
|
+
const namesMatch = imp.match(/import\s*\{([^}]+)\}/);
|
|
626
|
+
if (namesMatch) {
|
|
627
|
+
const names = namesMatch[1]
|
|
628
|
+
.split(',')
|
|
629
|
+
.map(n => n.trim())
|
|
630
|
+
.filter(Boolean)
|
|
631
|
+
.map(n => {
|
|
632
|
+
const parts = n.split(/\s+as\s+/).map(p => p.trim()).filter(Boolean);
|
|
633
|
+
return parts[1] || parts[0] || '';
|
|
634
|
+
})
|
|
635
|
+
.filter(Boolean);
|
|
636
|
+
for (const name of names) {
|
|
637
|
+
layoutFnToModule[name] = moduleId;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const starMatch = imp.match(/import\s*\*\s*as\s+(\w+)/);
|
|
641
|
+
if (starMatch) {
|
|
642
|
+
layoutFnToModule[starMatch[1]] = moduleId;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// Rewrite function calls in the compiled layout body
|
|
646
|
+
for (const [fnName, moduleId] of Object.entries(layoutFnToModule)) {
|
|
647
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(fnName))
|
|
648
|
+
continue;
|
|
649
|
+
const callRegex = new RegExp(`\\b${fnName}\\s*\\(`, 'g');
|
|
650
|
+
compiledLayout = compiledLayout.replace(callRegex, `${moduleId}.${fnName}(`);
|
|
651
|
+
}
|
|
652
|
+
// Generate layout actions map for action={fn} in layouts
|
|
653
|
+
if (layoutParsedForImports.actionFunctions.length > 0) {
|
|
654
|
+
const actionEntries = layoutParsedForImports.actionFunctions
|
|
655
|
+
.filter(fn => fn in layoutFnToModule)
|
|
656
|
+
.map(fn => `'${fn}': ${layoutFnToModule[fn]}.${fn}`)
|
|
657
|
+
.join(', ');
|
|
658
|
+
if (actionEntries) {
|
|
659
|
+
compiledLayoutActions = `{ ${actionEntries} }`;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
// Detect if the compiled layout uses await → make it async
|
|
664
|
+
isLayoutAsync = /\bawait\b/.test(compiledLayout);
|
|
665
|
+
if (isLayoutAsync) {
|
|
666
|
+
compiledLayout = compiledLayout.replace(/^function __layout\(/, 'async function __layout(');
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
for (let i = 0; i < routeFiles.length; i++) {
|
|
670
|
+
const rf = routeFiles[i];
|
|
671
|
+
const fullPath = path.join(routesDir, rf.file);
|
|
672
|
+
const source = fs.readFileSync(fullPath, 'utf-8');
|
|
673
|
+
const parsed = parseFile(source);
|
|
674
|
+
const pattern = filePathToPattern(rf.name);
|
|
675
|
+
// Build a mapping: functionName → moduleId for all imports in this route
|
|
676
|
+
const fnToModule = {};
|
|
677
|
+
const outFileDir = path.join(projectDir, '.kuratchi');
|
|
678
|
+
if (parsed.serverImports.length > 0) {
|
|
679
|
+
const routeFileDir = path.dirname(fullPath);
|
|
680
|
+
for (const imp of parsed.serverImports) {
|
|
681
|
+
const pathMatch = imp.match(/from\s+['"]([^'"]+)['"]/);
|
|
682
|
+
if (!pathMatch)
|
|
683
|
+
continue;
|
|
684
|
+
const origPath = pathMatch[1];
|
|
685
|
+
// Bare module specifiers (packages) — pass through as-is
|
|
686
|
+
const isBareModule = !origPath.startsWith('.') && !origPath.startsWith('/') && !origPath.startsWith('$');
|
|
687
|
+
let importPath;
|
|
688
|
+
if (isBareModule) {
|
|
689
|
+
// Package import: @kuratchi/auth, KuratchiJS, cloudflare:workers, etc.
|
|
690
|
+
importPath = origPath;
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
let absImport;
|
|
694
|
+
if (origPath.startsWith('$')) {
|
|
695
|
+
// Dynamic $folder/ alias → src/folder/
|
|
696
|
+
const slashIdx = origPath.indexOf('/');
|
|
697
|
+
const folder = origPath.slice(1, slashIdx);
|
|
698
|
+
const rest = origPath.slice(slashIdx + 1);
|
|
699
|
+
absImport = path.join(srcDir, folder, rest);
|
|
700
|
+
// Redirect DO handler imports to generated proxy modules
|
|
701
|
+
const doProxyPath = doHandlerProxyPaths.get(absImport.replace(/\\/g, '/'));
|
|
702
|
+
if (doProxyPath) {
|
|
703
|
+
absImport = doProxyPath;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
// Resolve the import relative to the route file
|
|
708
|
+
absImport = path.resolve(routeFileDir, origPath);
|
|
709
|
+
}
|
|
710
|
+
// Make it relative to the output directory
|
|
711
|
+
let relPath = path.relative(outFileDir, absImport).replace(/\\/g, '/');
|
|
712
|
+
if (!relPath.startsWith('.'))
|
|
713
|
+
relPath = './' + relPath;
|
|
714
|
+
// Check if the resolved file exists (try .ts, .js extensions)
|
|
715
|
+
let resolvedExt = '';
|
|
716
|
+
for (const ext of ['.ts', '.js', '/index.ts', '/index.js']) {
|
|
717
|
+
if (fs.existsSync(absImport + ext)) {
|
|
718
|
+
resolvedExt = ext;
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
importPath = relPath + resolvedExt;
|
|
723
|
+
}
|
|
724
|
+
const moduleId = `__m${moduleCounter++}`;
|
|
725
|
+
allImports.push(`import * as ${moduleId} from '${importPath}';`);
|
|
726
|
+
// Extract named imports and map them to this module
|
|
727
|
+
const namesMatch = imp.match(/import\s*\{([^}]+)\}/);
|
|
728
|
+
if (namesMatch) {
|
|
729
|
+
const names = namesMatch[1]
|
|
730
|
+
.split(',')
|
|
731
|
+
.map(n => n.trim())
|
|
732
|
+
.filter(Boolean)
|
|
733
|
+
.map(n => {
|
|
734
|
+
const parts = n.split(/\s+as\s+/).map(p => p.trim()).filter(Boolean);
|
|
735
|
+
return parts[1] || parts[0] || '';
|
|
736
|
+
})
|
|
737
|
+
.filter(Boolean);
|
|
738
|
+
for (const name of names) {
|
|
739
|
+
fnToModule[name] = moduleId;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
// Handle: import * as X from '...'
|
|
743
|
+
const starMatch = imp.match(/import\s*\*\s*as\s+(\w+)/);
|
|
744
|
+
if (starMatch) {
|
|
745
|
+
fnToModule[starMatch[1]] = moduleId;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// Build per-route component names from explicit imports
|
|
750
|
+
// componentImports: { StatCard: 'stat-card' } → componentNames maps PascalCase → fileName
|
|
751
|
+
const routeComponentNames = new Map();
|
|
752
|
+
for (const [pascalName, fileName] of Object.entries(parsed.componentImports)) {
|
|
753
|
+
// Compile the component on first use
|
|
754
|
+
compileComponent(fileName);
|
|
755
|
+
routeComponentNames.set(pascalName, fileName);
|
|
756
|
+
}
|
|
757
|
+
// Discover action functions passed as props to components.
|
|
758
|
+
// A component like db-studio uses action={runQueryAction} where runQueryAction is a prop.
|
|
759
|
+
// When the route passes runQueryAction={runAdminSqlQuery}, we need runAdminSqlQuery in
|
|
760
|
+
// the route's actions map so the runtime can dispatch it.
|
|
761
|
+
// Strategy: for each component, we know which prop names are action props (from cache).
|
|
762
|
+
// We then scan the route template for that component's usage and extract the bound values.
|
|
763
|
+
for (const [pascalName, compFileName] of routeComponentNames.entries()) {
|
|
764
|
+
const actionPropNames = componentActionCache.get(compFileName);
|
|
765
|
+
if (!actionPropNames || actionPropNames.size === 0)
|
|
766
|
+
continue;
|
|
767
|
+
// Find all usages of <PascalName ...> in the route template and extract prop bindings.
|
|
768
|
+
// Match <ComponentName ... propName={value} ... > across multiple lines.
|
|
769
|
+
const compTagRegex = new RegExp(`<${pascalName}\\b([\\s\\S]*?)/>`, 'g');
|
|
770
|
+
for (const tagMatch of parsed.template.matchAll(compTagRegex)) {
|
|
771
|
+
const attrs = tagMatch[1];
|
|
772
|
+
for (const propName of actionPropNames) {
|
|
773
|
+
// Find propName={identifier} binding
|
|
774
|
+
const propRegex = new RegExp(`\\b${propName}=\\{([A-Za-z_$][\\w$]*)\\}`);
|
|
775
|
+
const propMatch = attrs.match(propRegex);
|
|
776
|
+
if (propMatch) {
|
|
777
|
+
const routeFnName = propMatch[1];
|
|
778
|
+
// Only add if this function is actually imported by the route
|
|
779
|
+
if (routeFnName in fnToModule && !parsed.actionFunctions.includes(routeFnName)) {
|
|
780
|
+
parsed.actionFunctions.push(routeFnName);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
// Compile template to render function body (pass component names and action names)
|
|
787
|
+
// An identifier is a valid server action if it is either:
|
|
788
|
+
// 1. Directly imported (present in fnToModule), or
|
|
789
|
+
// 2. A top-level script declaration (present in dataVars) — covers cases like
|
|
790
|
+
// `const fn = importedFn` or `async function fn() {}` where the binding
|
|
791
|
+
// is locally declared but delegates to an imported function.
|
|
792
|
+
const dataVarsSet = new Set(parsed.dataVars);
|
|
793
|
+
const actionNames = new Set(parsed.actionFunctions.filter(fn => fn in fnToModule || dataVarsSet.has(fn)));
|
|
794
|
+
// Opaque per-route RPC IDs keep implementation details out of rendered HTML.
|
|
795
|
+
const rpcNameMap = new Map();
|
|
796
|
+
let rpcCounter = 0;
|
|
797
|
+
for (const fnName of parsed.pollFunctions) {
|
|
798
|
+
if (!rpcNameMap.has(fnName)) {
|
|
799
|
+
rpcNameMap.set(fnName, `rpc_${i}_${rpcCounter++}`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
for (const q of parsed.dataGetQueries) {
|
|
803
|
+
if (!rpcNameMap.has(q.fnName)) {
|
|
804
|
+
rpcNameMap.set(q.fnName, `rpc_${i}_${rpcCounter++}`);
|
|
805
|
+
}
|
|
806
|
+
q.rpcId = rpcNameMap.get(q.fnName);
|
|
807
|
+
}
|
|
808
|
+
// Apply nested route layouts (excluding root layout.html which is handled globally)
|
|
809
|
+
let effectiveTemplate = parsed.template;
|
|
810
|
+
for (const layoutRelPath of rf.layouts) {
|
|
811
|
+
if (layoutRelPath === 'layout.html')
|
|
812
|
+
continue;
|
|
813
|
+
const layoutPath = path.join(routesDir, layoutRelPath);
|
|
814
|
+
if (!fs.existsSync(layoutPath))
|
|
815
|
+
continue;
|
|
816
|
+
const layoutSource = fs.readFileSync(layoutPath, 'utf-8');
|
|
817
|
+
const layoutSlot = layoutSource.match(/<slot\s*><\/slot>|<slot\s*\/>/);
|
|
818
|
+
if (!layoutSlot) {
|
|
819
|
+
throw new Error(`${layoutRelPath} must contain <slot></slot> or <slot />`);
|
|
820
|
+
}
|
|
821
|
+
effectiveTemplate = layoutSource.replace(layoutSlot[0], effectiveTemplate);
|
|
822
|
+
}
|
|
823
|
+
const renderBody = compileTemplate(effectiveTemplate, routeComponentNames, actionNames, rpcNameMap);
|
|
824
|
+
// Collect component CSS for this route (compile-time dedup)
|
|
825
|
+
const routeComponentStyles = [];
|
|
826
|
+
for (const fileName of routeComponentNames.values()) {
|
|
827
|
+
const css = componentStyleCache.get(fileName);
|
|
828
|
+
if (css)
|
|
829
|
+
routeComponentStyles.push(css);
|
|
830
|
+
}
|
|
831
|
+
// Build the route module object
|
|
832
|
+
const routeObj = buildRouteObject({
|
|
833
|
+
index: i,
|
|
834
|
+
pattern,
|
|
835
|
+
renderBody,
|
|
836
|
+
parsed,
|
|
837
|
+
fnToModule,
|
|
838
|
+
rpcNameMap,
|
|
839
|
+
componentStyles: routeComponentStyles,
|
|
840
|
+
});
|
|
841
|
+
compiledRoutes.push(routeObj);
|
|
842
|
+
}
|
|
843
|
+
// Scan src/assets/ for static files to embed
|
|
844
|
+
const assetsDir = path.join(srcDir, 'assets');
|
|
845
|
+
const compiledAssets = [];
|
|
846
|
+
if (fs.existsSync(assetsDir)) {
|
|
847
|
+
const mimeTypes = {
|
|
848
|
+
'.css': 'text/css; charset=utf-8',
|
|
849
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
850
|
+
'.json': 'application/json; charset=utf-8',
|
|
851
|
+
'.svg': 'image/svg+xml',
|
|
852
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
853
|
+
};
|
|
854
|
+
for (const file of fs.readdirSync(assetsDir).sort()) {
|
|
855
|
+
const ext = path.extname(file).toLowerCase();
|
|
856
|
+
const mime = mimeTypes[ext];
|
|
857
|
+
if (!mime)
|
|
858
|
+
continue; // skip unknown file types
|
|
859
|
+
const content = fs.readFileSync(path.join(assetsDir, file), 'utf-8');
|
|
860
|
+
const etag = '"' + crypto.createHash('md5').update(content).digest('hex').slice(0, 12) + '"';
|
|
861
|
+
compiledAssets.push({ name: file, content, mime, etag });
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
// Collect only the components that were actually imported by routes
|
|
865
|
+
const compiledComponents = Array.from(compiledComponentCache.values());
|
|
866
|
+
// Generate the routes module
|
|
867
|
+
const output = generateRoutesModule({
|
|
868
|
+
projectDir,
|
|
869
|
+
serverImports: allImports,
|
|
870
|
+
compiledRoutes,
|
|
871
|
+
compiledLayout,
|
|
872
|
+
compiledComponents,
|
|
873
|
+
compiledAssets,
|
|
874
|
+
compiledErrorPages,
|
|
875
|
+
ormDatabases,
|
|
876
|
+
authConfig,
|
|
877
|
+
doConfig,
|
|
878
|
+
doHandlers,
|
|
879
|
+
isDev: options.isDev ?? false,
|
|
880
|
+
isLayoutAsync,
|
|
881
|
+
compiledLayoutActions,
|
|
882
|
+
});
|
|
883
|
+
// Write to .kuratchi/routes.js
|
|
884
|
+
const outFile = options.outFile ?? path.join(projectDir, '.kuratchi', 'routes.js');
|
|
885
|
+
const outDir = path.dirname(outFile);
|
|
886
|
+
if (!fs.existsSync(outDir)) {
|
|
887
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
888
|
+
}
|
|
889
|
+
writeIfChanged(outFile, output);
|
|
890
|
+
return outFile;
|
|
891
|
+
}
|
|
892
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
893
|
+
/**
|
|
894
|
+
* Write a file only if its content has changed.
|
|
895
|
+
* Prevents unnecessary filesystem events that would retrigger wrangler's file watcher.
|
|
896
|
+
*/
|
|
897
|
+
function writeIfChanged(filePath, content) {
|
|
898
|
+
if (fs.existsSync(filePath)) {
|
|
899
|
+
const existing = fs.readFileSync(filePath, 'utf-8');
|
|
900
|
+
if (existing === content)
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
904
|
+
}
|
|
905
|
+
function skipWhitespace(source, start) {
|
|
906
|
+
let i = start;
|
|
907
|
+
while (i < source.length && /\s/.test(source[i]))
|
|
908
|
+
i++;
|
|
909
|
+
return i;
|
|
910
|
+
}
|
|
911
|
+
function extractBalancedBody(source, start, openChar, closeChar) {
|
|
912
|
+
if (source[start] !== openChar)
|
|
913
|
+
return null;
|
|
914
|
+
let depth = 0;
|
|
915
|
+
for (let i = start; i < source.length; i++) {
|
|
916
|
+
if (source[i] === openChar)
|
|
917
|
+
depth++;
|
|
918
|
+
else if (source[i] === closeChar) {
|
|
919
|
+
depth--;
|
|
920
|
+
if (depth === 0)
|
|
921
|
+
return source.slice(start + 1, i);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
function readConfigBlock(source, key) {
|
|
927
|
+
const keyRegex = new RegExp(`\\b${key}\\s*:`);
|
|
928
|
+
const keyMatch = keyRegex.exec(source);
|
|
929
|
+
if (!keyMatch)
|
|
930
|
+
return null;
|
|
931
|
+
const colonIdx = source.indexOf(':', keyMatch.index);
|
|
932
|
+
if (colonIdx === -1)
|
|
933
|
+
return null;
|
|
934
|
+
const valueIdx = skipWhitespace(source, colonIdx + 1);
|
|
935
|
+
if (valueIdx >= source.length)
|
|
936
|
+
return null;
|
|
937
|
+
if (source[valueIdx] === '{') {
|
|
938
|
+
throw new Error(`[kuratchi] "${key}" config must use an adapter call (e.g. ${key}: kuratchi${key[0].toUpperCase()}${key.slice(1)}Config({...})).`);
|
|
939
|
+
}
|
|
940
|
+
const callOpen = source.indexOf('(', valueIdx);
|
|
941
|
+
if (callOpen === -1)
|
|
942
|
+
return null;
|
|
943
|
+
const argIdx = skipWhitespace(source, callOpen + 1);
|
|
944
|
+
if (argIdx >= source.length)
|
|
945
|
+
return null;
|
|
946
|
+
if (source[argIdx] === ')')
|
|
947
|
+
return { kind: 'call-empty', body: '' };
|
|
948
|
+
if (source[argIdx] === '{') {
|
|
949
|
+
const body = extractBalancedBody(source, argIdx, '{', '}');
|
|
950
|
+
if (body == null)
|
|
951
|
+
return null;
|
|
952
|
+
return { kind: 'call-object', body };
|
|
953
|
+
}
|
|
954
|
+
return { kind: 'call-empty', body: '' };
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Read ui.theme from kuratchi.config.ts and return the theme CSS content.
|
|
958
|
+
* Returns null if no theme is configured.
|
|
959
|
+
*/
|
|
960
|
+
function readUiTheme(projectDir) {
|
|
961
|
+
const configPath = path.join(projectDir, 'kuratchi.config.ts');
|
|
962
|
+
if (!fs.existsSync(configPath))
|
|
963
|
+
return null;
|
|
964
|
+
const source = fs.readFileSync(configPath, 'utf-8');
|
|
965
|
+
const uiBlock = readConfigBlock(source, 'ui');
|
|
966
|
+
if (!uiBlock)
|
|
967
|
+
return null;
|
|
968
|
+
// Adapter form defaults to "default" theme when ui config is present.
|
|
969
|
+
const themeMatch = uiBlock.body.match(/theme\s*:\s*['"]([^'"]+)['"]/);
|
|
970
|
+
const themeValue = themeMatch?.[1] ?? 'default';
|
|
971
|
+
if (themeValue === 'default') {
|
|
972
|
+
// Resolve @kuratchi/ui/src/styles/theme.css from package
|
|
973
|
+
const candidates = [
|
|
974
|
+
path.join(projectDir, 'node_modules', '@kuratchi/ui', 'src', 'styles', 'theme.css'),
|
|
975
|
+
path.join(path.resolve(projectDir, '../..'), 'packages', 'kuratchi-ui', 'src', 'styles', 'theme.css'),
|
|
976
|
+
path.join(path.resolve(projectDir, '../..'), 'node_modules', '@kuratchi/ui', 'src', 'styles', 'theme.css'),
|
|
977
|
+
];
|
|
978
|
+
for (const candidate of candidates) {
|
|
979
|
+
if (fs.existsSync(candidate)) {
|
|
980
|
+
return fs.readFileSync(candidate, 'utf-8');
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
console.warn('[kuratchi] ui.theme: "default" configured but @kuratchi/ui theme.css not found');
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
// Custom path — resolve relative to project root
|
|
987
|
+
const customPath = path.resolve(projectDir, themeValue);
|
|
988
|
+
if (fs.existsSync(customPath)) {
|
|
989
|
+
return fs.readFileSync(customPath, 'utf-8');
|
|
990
|
+
}
|
|
991
|
+
console.warn(`[kuratchi] ui.theme: "${themeValue}" not found at ${customPath}`);
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Resolve a component .html file from a package (e.g. @kuratchi/ui).
|
|
996
|
+
* Searches: node_modules, then workspace siblings (../../packages/).
|
|
997
|
+
*/
|
|
998
|
+
function resolvePackageComponent(projectDir, pkgName, componentFile) {
|
|
999
|
+
// 1. Try node_modules (standard resolution)
|
|
1000
|
+
const nmPath = path.join(projectDir, 'node_modules', pkgName, 'src', 'lib', componentFile + '.html');
|
|
1001
|
+
if (fs.existsSync(nmPath))
|
|
1002
|
+
return nmPath;
|
|
1003
|
+
// 2. Try workspace layout: project is in apps/X or packages/X, sibling packages in packages/
|
|
1004
|
+
// @kuratchi/ui → kuratchi-ui (convention: scope stripped, slash → dash)
|
|
1005
|
+
const pkgDirName = pkgName.replace(/^@/, '').replace(/\//g, '-');
|
|
1006
|
+
const workspaceRoot = path.resolve(projectDir, '../..');
|
|
1007
|
+
const wsPath = path.join(workspaceRoot, 'packages', pkgDirName, 'src', 'lib', componentFile + '.html');
|
|
1008
|
+
if (fs.existsSync(wsPath))
|
|
1009
|
+
return wsPath;
|
|
1010
|
+
// 3. Try one level up (monorepo root node_modules)
|
|
1011
|
+
const rootNmPath = path.join(workspaceRoot, 'node_modules', pkgName, 'src', 'lib', componentFile + '.html');
|
|
1012
|
+
if (fs.existsSync(rootNmPath))
|
|
1013
|
+
return rootNmPath;
|
|
1014
|
+
return '';
|
|
1015
|
+
}
|
|
1016
|
+
function discoverRoutes(routesDir) {
|
|
1017
|
+
const results = [];
|
|
1018
|
+
const registered = new Set();
|
|
1019
|
+
function getLayoutsForPrefix(prefix) {
|
|
1020
|
+
const layouts = [];
|
|
1021
|
+
if (fs.existsSync(path.join(routesDir, 'layout.html')))
|
|
1022
|
+
layouts.push('layout.html');
|
|
1023
|
+
if (!prefix)
|
|
1024
|
+
return layouts;
|
|
1025
|
+
const parts = prefix.split('/').filter(Boolean);
|
|
1026
|
+
let current = '';
|
|
1027
|
+
for (const part of parts) {
|
|
1028
|
+
current = current ? `${current}/${part}` : part;
|
|
1029
|
+
const rel = `${current}/layout.html`;
|
|
1030
|
+
if (fs.existsSync(path.join(routesDir, rel)))
|
|
1031
|
+
layouts.push(rel);
|
|
1032
|
+
}
|
|
1033
|
+
return layouts;
|
|
1034
|
+
}
|
|
1035
|
+
function walk(dir, prefix) {
|
|
1036
|
+
if (!fs.existsSync(dir))
|
|
1037
|
+
return;
|
|
1038
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1039
|
+
for (const entry of entries) {
|
|
1040
|
+
if (entry.isDirectory()) {
|
|
1041
|
+
const childPrefix = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
1042
|
+
// Folder-based route: routes/db/page.html → /db
|
|
1043
|
+
const pageFile = path.join(dir, entry.name, 'page.html');
|
|
1044
|
+
if (fs.existsSync(pageFile)) {
|
|
1045
|
+
const routeFile = `${childPrefix}/page.html`;
|
|
1046
|
+
if (!registered.has(routeFile)) {
|
|
1047
|
+
registered.add(routeFile);
|
|
1048
|
+
results.push({ file: routeFile, name: childPrefix, layouts: getLayoutsForPrefix(childPrefix) });
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
// Always recurse into subdirectory (for nested routes like /admin/roles)
|
|
1052
|
+
walk(path.join(dir, entry.name), childPrefix);
|
|
1053
|
+
}
|
|
1054
|
+
else if (entry.name === 'layout.html' || entry.name === '404.html' || entry.name === '500.html') {
|
|
1055
|
+
// Skip — layout.html is the app layout, 404/500 are error pages, not routes
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
else if (entry.name === 'page.html') {
|
|
1059
|
+
// page.html in current directory → index route for this prefix
|
|
1060
|
+
const routeFile = prefix ? `${prefix}/page.html` : 'page.html';
|
|
1061
|
+
if (!registered.has(routeFile)) {
|
|
1062
|
+
registered.add(routeFile);
|
|
1063
|
+
results.push({ file: routeFile, name: prefix || 'index', layouts: getLayoutsForPrefix(prefix) });
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
else if (entry.name.endsWith('.html') && entry.name !== 'page.html') {
|
|
1067
|
+
// File-based route: routes/about.html → /about (fallback)
|
|
1068
|
+
const name = prefix
|
|
1069
|
+
? `${prefix}/${entry.name.replace('.html', '')}`
|
|
1070
|
+
: entry.name.replace('.html', '');
|
|
1071
|
+
results.push({
|
|
1072
|
+
file: prefix ? `${prefix}/${entry.name}` : entry.name,
|
|
1073
|
+
name,
|
|
1074
|
+
layouts: getLayoutsForPrefix(prefix),
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
walk(routesDir, '');
|
|
1080
|
+
// Sort: static routes first, then dynamic, then catch-all
|
|
1081
|
+
results.sort((a, b) => {
|
|
1082
|
+
const aScore = a.name.includes('[...') ? 2 : a.name.includes('[') ? 1 : 0;
|
|
1083
|
+
const bScore = b.name.includes('[...') ? 2 : b.name.includes('[') ? 1 : 0;
|
|
1084
|
+
return aScore - bScore || a.name.localeCompare(b.name);
|
|
1085
|
+
});
|
|
1086
|
+
return results;
|
|
1087
|
+
}
|
|
1088
|
+
function buildRouteObject(opts) {
|
|
1089
|
+
const { pattern, renderBody, parsed, fnToModule, rpcNameMap, componentStyles } = opts;
|
|
1090
|
+
const hasFns = Object.keys(fnToModule).length > 0;
|
|
1091
|
+
const parts = [];
|
|
1092
|
+
parts.push(` pattern: '${pattern}'`);
|
|
1093
|
+
// Load function — generated from the script block's top-level code + data-get queries
|
|
1094
|
+
const hasDataGetQueries = Array.isArray(parsed.dataGetQueries) && parsed.dataGetQueries.length > 0;
|
|
1095
|
+
if ((parsed.hasLoad && parsed.script) || hasDataGetQueries) {
|
|
1096
|
+
// Get script body (everything except imports)
|
|
1097
|
+
let body = parsed.script
|
|
1098
|
+
? parsed.script.replace(/^\s*import[\s\S]*?from\s+['"][^'"]+['"]\s*;?/gm, '').trim()
|
|
1099
|
+
: '';
|
|
1100
|
+
// Inject data-get query state blocks into load scope.
|
|
1101
|
+
// Each query exposes:
|
|
1102
|
+
// { state, loading, error, data, empty, success }
|
|
1103
|
+
if (hasDataGetQueries) {
|
|
1104
|
+
const queries = parsed.dataGetQueries;
|
|
1105
|
+
const queryLines = [];
|
|
1106
|
+
for (const q of queries) {
|
|
1107
|
+
const fnName = q.fnName;
|
|
1108
|
+
const rpcId = q.rpcId || rpcNameMap?.get(fnName) || fnName;
|
|
1109
|
+
const argsExpr = (q.argsExpr || '').trim();
|
|
1110
|
+
const asName = q.asName;
|
|
1111
|
+
const defaultArgs = argsExpr ? `[${argsExpr}]` : '[]';
|
|
1112
|
+
queryLines.push(`let ${asName} = { state: 'loading', loading: true, error: null, data: null, empty: false, success: false };`);
|
|
1113
|
+
queryLines.push(`const __qOverride_${asName} = __getLocals().__queryOverride;`);
|
|
1114
|
+
queryLines.push(`const __qArgs_${asName} = ${defaultArgs};`);
|
|
1115
|
+
queryLines.push(`const __qShouldRun_${asName} = !!(__qOverride_${asName} && __qOverride_${asName}.fn === '${rpcId}' && Array.isArray(__qOverride_${asName}.args) && JSON.stringify(__qOverride_${asName}.args) === JSON.stringify(__qArgs_${asName}));`);
|
|
1116
|
+
queryLines.push(`if (__qShouldRun_${asName}) {`);
|
|
1117
|
+
queryLines.push(` try {`);
|
|
1118
|
+
queryLines.push(` const __qData_${asName} = await ${fnName}(...__qArgs_${asName});`);
|
|
1119
|
+
queryLines.push(` const __qEmpty_${asName} = Array.isArray(__qData_${asName}) ? __qData_${asName}.length === 0 : (__qData_${asName} == null);`);
|
|
1120
|
+
queryLines.push(` ${asName} = { state: __qEmpty_${asName} ? 'empty' : 'success', loading: false, error: null, data: __qData_${asName}, empty: __qEmpty_${asName}, success: !__qEmpty_${asName} };`);
|
|
1121
|
+
queryLines.push(` } catch (err) {`);
|
|
1122
|
+
queryLines.push(` const __qErr_${asName} = (err && err.message) ? String(err.message) : String(err);`);
|
|
1123
|
+
queryLines.push(` ${asName} = { state: 'error', loading: false, error: __qErr_${asName}, data: null, empty: false, success: false };`);
|
|
1124
|
+
queryLines.push(` }`);
|
|
1125
|
+
queryLines.push(`}`);
|
|
1126
|
+
}
|
|
1127
|
+
body = [body, queryLines.join('\n')].filter(Boolean).join('\n');
|
|
1128
|
+
}
|
|
1129
|
+
// Rewrite imported function calls: fnName( → __mN.fnName(
|
|
1130
|
+
// No env injection — server functions use getEnv() from the framework context
|
|
1131
|
+
for (const [fnName, moduleId] of Object.entries(fnToModule)) {
|
|
1132
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(fnName))
|
|
1133
|
+
continue;
|
|
1134
|
+
const callRegex = new RegExp(`\\b${fnName}\\s*\\(`, 'g');
|
|
1135
|
+
body = body.replace(callRegex, `${moduleId}.${fnName}(`);
|
|
1136
|
+
}
|
|
1137
|
+
// Determine if body uses await
|
|
1138
|
+
const isAsync = /\bawait\b/.test(body) || hasDataGetQueries;
|
|
1139
|
+
// Return an object with all declared data variables
|
|
1140
|
+
const returnObj = parsed.dataVars.length > 0
|
|
1141
|
+
? `\n return { ${parsed.dataVars.join(', ')} };`
|
|
1142
|
+
: '';
|
|
1143
|
+
parts.push(` ${isAsync ? 'async ' : ''}load(params = {}) {\n ${body}${returnObj}\n }`);
|
|
1144
|
+
}
|
|
1145
|
+
// Actions — functions referenced via action={fn} in the template
|
|
1146
|
+
if (hasFns && parsed.actionFunctions.length > 0) {
|
|
1147
|
+
const actionEntries = parsed.actionFunctions
|
|
1148
|
+
.map(fn => {
|
|
1149
|
+
const moduleId = fnToModule[fn];
|
|
1150
|
+
return moduleId ? `'${fn}': ${moduleId}.${fn}` : `'${fn}': ${fn}`;
|
|
1151
|
+
})
|
|
1152
|
+
.join(', ');
|
|
1153
|
+
parts.push(` actions: { ${actionEntries} }`);
|
|
1154
|
+
}
|
|
1155
|
+
// RPC — functions referenced via data-poll={fn(args)} in the template
|
|
1156
|
+
if (hasFns && parsed.pollFunctions.length > 0) {
|
|
1157
|
+
const rpcEntries = parsed.pollFunctions
|
|
1158
|
+
.map(fn => {
|
|
1159
|
+
const moduleId = fnToModule[fn];
|
|
1160
|
+
const rpcId = rpcNameMap?.get(fn) || fn;
|
|
1161
|
+
return moduleId ? `'${rpcId}': ${moduleId}.${fn}` : `'${rpcId}': ${fn}`;
|
|
1162
|
+
})
|
|
1163
|
+
.join(', ');
|
|
1164
|
+
parts.push(` rpc: { ${rpcEntries} }`);
|
|
1165
|
+
}
|
|
1166
|
+
// Render function — template compiled to JS with native flow control
|
|
1167
|
+
// Destructure data vars so templates reference them directly (e.g., {todos} not {data.todos})
|
|
1168
|
+
// Always include __error so templates can show form action errors via {__error}
|
|
1169
|
+
const allVars = [...parsed.dataVars];
|
|
1170
|
+
if (!allVars.includes('__error'))
|
|
1171
|
+
allVars.push('__error');
|
|
1172
|
+
if (!allVars.includes('params'))
|
|
1173
|
+
allVars.push('params');
|
|
1174
|
+
if (!allVars.includes('breadcrumbs'))
|
|
1175
|
+
allVars.push('breadcrumbs');
|
|
1176
|
+
const destructure = `const { ${allVars.join(', ')} } = data;\n `;
|
|
1177
|
+
// Inject component CSS at compile time (once per route, no runtime dedup)
|
|
1178
|
+
// Must come after 'let __html = "";' (first line of renderBody)
|
|
1179
|
+
let finalRenderBody = renderBody;
|
|
1180
|
+
if (componentStyles.length > 0) {
|
|
1181
|
+
const lines = renderBody.split('\n');
|
|
1182
|
+
const styleLines = componentStyles.map(css => `__html += \`${css}\\n\`;`);
|
|
1183
|
+
finalRenderBody = [lines[0], ...styleLines, ...lines.slice(1)].join('\n');
|
|
1184
|
+
}
|
|
1185
|
+
parts.push(` render(data) {
|
|
1186
|
+
${destructure}${finalRenderBody}
|
|
1187
|
+
return __html;
|
|
1188
|
+
}`);
|
|
1189
|
+
return ` {\n${parts.join(',\n')}\n }`;
|
|
1190
|
+
}
|
|
1191
|
+
function readOrmConfig(projectDir) {
|
|
1192
|
+
const configPath = path.join(projectDir, 'kuratchi.config.ts');
|
|
1193
|
+
if (!fs.existsSync(configPath))
|
|
1194
|
+
return [];
|
|
1195
|
+
const source = fs.readFileSync(configPath, 'utf-8');
|
|
1196
|
+
const ormBlock = readConfigBlock(source, 'orm');
|
|
1197
|
+
if (!ormBlock)
|
|
1198
|
+
return [];
|
|
1199
|
+
// Extract schema imports: import { todoSchema } from './src/schemas/todo';
|
|
1200
|
+
const importMap = new Map(); // exportName → importPath
|
|
1201
|
+
const importRegex = /import\s*\{\s*([^}]+)\s*\}\s*from\s*['"]([^'"]+)['"]/g;
|
|
1202
|
+
let m;
|
|
1203
|
+
while ((m = importRegex.exec(source)) !== null) {
|
|
1204
|
+
const names = m[1].split(',').map(n => n.trim()).filter(Boolean);
|
|
1205
|
+
const importPath = m[2];
|
|
1206
|
+
for (const name of names) {
|
|
1207
|
+
importMap.set(name, importPath);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
const databasesIdx = ormBlock.body.search(/databases\s*:\s*\{/);
|
|
1211
|
+
if (databasesIdx === -1)
|
|
1212
|
+
return [];
|
|
1213
|
+
const dbBraceStart = ormBlock.body.indexOf('{', databasesIdx);
|
|
1214
|
+
if (dbBraceStart === -1)
|
|
1215
|
+
return [];
|
|
1216
|
+
const databasesBody = extractBalancedBody(ormBlock.body, dbBraceStart, '{', '}');
|
|
1217
|
+
if (databasesBody == null)
|
|
1218
|
+
return [];
|
|
1219
|
+
// Pattern: BINDING: { schema: schemaName, skipMigrations?: true/false }
|
|
1220
|
+
const entries = [];
|
|
1221
|
+
const entryRegex = /(\w+)\s*:\s*\{\s*schema\s*:\s*(\w+)([^}]*)\}/g;
|
|
1222
|
+
while ((m = entryRegex.exec(databasesBody)) !== null) {
|
|
1223
|
+
const binding = m[1];
|
|
1224
|
+
const schemaExportName = m[2];
|
|
1225
|
+
const rest = m[3] || '';
|
|
1226
|
+
const skipMatch = rest.match(/skipMigrations\s*:\s*(true|false)/);
|
|
1227
|
+
const skipMigrations = skipMatch?.[1] === 'true';
|
|
1228
|
+
const typeMatch = rest.match(/type\s*:\s*['"]?(d1|do)['"]?/);
|
|
1229
|
+
const type = typeMatch?.[1] ?? 'd1';
|
|
1230
|
+
// Only include if the schema name maps to a known import (not 'orm', 'databases', etc.)
|
|
1231
|
+
const schemaImportPath = importMap.get(schemaExportName);
|
|
1232
|
+
if (!schemaImportPath)
|
|
1233
|
+
continue;
|
|
1234
|
+
entries.push({ binding, schemaImportPath, schemaExportName, skipMigrations, type });
|
|
1235
|
+
}
|
|
1236
|
+
return entries;
|
|
1237
|
+
}
|
|
1238
|
+
function readAuthConfig(projectDir) {
|
|
1239
|
+
const configPath = path.join(projectDir, 'kuratchi.config.ts');
|
|
1240
|
+
if (!fs.existsSync(configPath))
|
|
1241
|
+
return null;
|
|
1242
|
+
const source = fs.readFileSync(configPath, 'utf-8');
|
|
1243
|
+
const authBlockMatch = readConfigBlock(source, 'auth');
|
|
1244
|
+
if (!authBlockMatch)
|
|
1245
|
+
return null;
|
|
1246
|
+
const authBlock = authBlockMatch.body;
|
|
1247
|
+
const cookieMatch = authBlock.match(/cookieName\s*:\s*['"]([^'"]+)['"]/);
|
|
1248
|
+
const secretMatch = authBlock.match(/secretEnvKey\s*:\s*['"]([^'"]+)['"]/);
|
|
1249
|
+
const sessionMatch = authBlock.match(/sessionEnabled\s*:\s*(true|false)/);
|
|
1250
|
+
// Detect sub-configs by looking for the key followed by a colon
|
|
1251
|
+
const hasCredentials = /credentials\s*:/.test(authBlock);
|
|
1252
|
+
const hasActivity = /activity\s*:/.test(authBlock);
|
|
1253
|
+
const hasRoles = /roles\s*:/.test(authBlock);
|
|
1254
|
+
const hasOAuth = /oauth\s*:/.test(authBlock);
|
|
1255
|
+
const hasGuards = /guards\s*:/.test(authBlock);
|
|
1256
|
+
const hasRateLimit = /rateLimit\s*:/.test(authBlock);
|
|
1257
|
+
const hasTurnstile = /turnstile\s*:/.test(authBlock);
|
|
1258
|
+
const hasOrganization = /organizations\s*:/.test(authBlock);
|
|
1259
|
+
return {
|
|
1260
|
+
cookieName: cookieMatch?.[1] ?? 'kuratchi_session',
|
|
1261
|
+
secretEnvKey: secretMatch?.[1] ?? 'AUTH_SECRET',
|
|
1262
|
+
sessionEnabled: sessionMatch?.[1] !== 'false',
|
|
1263
|
+
hasCredentials,
|
|
1264
|
+
hasActivity,
|
|
1265
|
+
hasRoles,
|
|
1266
|
+
hasOAuth,
|
|
1267
|
+
hasGuards,
|
|
1268
|
+
hasRateLimit,
|
|
1269
|
+
hasTurnstile,
|
|
1270
|
+
hasOrganization,
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
function toSafeIdentifier(input) {
|
|
1274
|
+
const normalized = input.replace(/[^A-Za-z0-9_$]/g, '_');
|
|
1275
|
+
return /^[A-Za-z_$]/.test(normalized) ? normalized : `_${normalized}`;
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Parse durableObjects config from kuratchi.config.ts.
|
|
1279
|
+
*
|
|
1280
|
+
* Supports both string shorthand and object form:
|
|
1281
|
+
* durableObjects: {
|
|
1282
|
+
* ORG_DB: { className: 'OrganizationDO', stubId: 'user.orgId' },
|
|
1283
|
+
* CACHE_DB: 'CacheDO'
|
|
1284
|
+
* }
|
|
1285
|
+
*/
|
|
1286
|
+
function readDoConfig(projectDir) {
|
|
1287
|
+
const configPath = path.join(projectDir, 'kuratchi.config.ts');
|
|
1288
|
+
if (!fs.existsSync(configPath))
|
|
1289
|
+
return [];
|
|
1290
|
+
const source = fs.readFileSync(configPath, 'utf-8');
|
|
1291
|
+
// Find durableObjects block
|
|
1292
|
+
const doIdx = source.search(/durableObjects\s*:\s*\{/);
|
|
1293
|
+
if (doIdx === -1)
|
|
1294
|
+
return [];
|
|
1295
|
+
const braceStart = source.indexOf('{', doIdx);
|
|
1296
|
+
if (braceStart === -1)
|
|
1297
|
+
return [];
|
|
1298
|
+
// Balance braces
|
|
1299
|
+
let depth = 0, braceEnd = braceStart;
|
|
1300
|
+
for (let i = braceStart; i < source.length; i++) {
|
|
1301
|
+
if (source[i] === '{')
|
|
1302
|
+
depth++;
|
|
1303
|
+
else if (source[i] === '}') {
|
|
1304
|
+
depth--;
|
|
1305
|
+
if (depth === 0) {
|
|
1306
|
+
braceEnd = i;
|
|
1307
|
+
break;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
const doBlock = source.slice(braceStart + 1, braceEnd);
|
|
1312
|
+
const entries = [];
|
|
1313
|
+
// Match object form: BINDING: { className: '...', stubId: '...' }
|
|
1314
|
+
const objRegex = /(\w+)\s*:\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/g;
|
|
1315
|
+
let m;
|
|
1316
|
+
while ((m = objRegex.exec(doBlock)) !== null) {
|
|
1317
|
+
const binding = m[1];
|
|
1318
|
+
const body = m[2];
|
|
1319
|
+
const cnMatch = body.match(/className\s*:\s*['"](\w+)['"]/);
|
|
1320
|
+
if (!cnMatch)
|
|
1321
|
+
continue;
|
|
1322
|
+
const entry = { binding, className: cnMatch[1] };
|
|
1323
|
+
const stubIdMatch = body.match(/stubId\s*:\s*['"]([^'"]+)['"]/);
|
|
1324
|
+
if (stubIdMatch)
|
|
1325
|
+
entry.stubId = stubIdMatch[1];
|
|
1326
|
+
const filesMatch = body.match(/files\s*:\s*\[([\s\S]*?)\]/);
|
|
1327
|
+
if (filesMatch) {
|
|
1328
|
+
const list = [];
|
|
1329
|
+
const itemRegex = /['"]([^'"]+)['"]/g;
|
|
1330
|
+
let fm;
|
|
1331
|
+
while ((fm = itemRegex.exec(filesMatch[1])) !== null) {
|
|
1332
|
+
list.push(fm[1]);
|
|
1333
|
+
}
|
|
1334
|
+
if (list.length > 0)
|
|
1335
|
+
entry.files = list;
|
|
1336
|
+
}
|
|
1337
|
+
// (inject config removed — DO methods are org-scoped, no auto-injection needed)
|
|
1338
|
+
entries.push(entry);
|
|
1339
|
+
}
|
|
1340
|
+
// Match string shorthand: BINDING: 'ClassName' (skip bindings already found)
|
|
1341
|
+
const foundBindings = new Set(entries.map(e => e.binding));
|
|
1342
|
+
const pairRegex = /(\w+)\s*:\s*['"](\w+)['"]\s*[,}\n]/g;
|
|
1343
|
+
while ((m = pairRegex.exec(doBlock)) !== null) {
|
|
1344
|
+
if (foundBindings.has(m[1]))
|
|
1345
|
+
continue;
|
|
1346
|
+
// Make sure this isn't a nested key like 'className'
|
|
1347
|
+
if (['className', 'stubId'].includes(m[1]))
|
|
1348
|
+
continue;
|
|
1349
|
+
entries.push({ binding: m[1], className: m[2] });
|
|
1350
|
+
}
|
|
1351
|
+
return entries;
|
|
1352
|
+
}
|
|
1353
|
+
function discoverFilesWithSuffix(dir, suffix) {
|
|
1354
|
+
if (!fs.existsSync(dir))
|
|
1355
|
+
return [];
|
|
1356
|
+
const out = [];
|
|
1357
|
+
const walk = (absDir) => {
|
|
1358
|
+
for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
|
|
1359
|
+
const abs = path.join(absDir, entry.name);
|
|
1360
|
+
if (entry.isDirectory()) {
|
|
1361
|
+
walk(abs);
|
|
1362
|
+
}
|
|
1363
|
+
else if (entry.isFile() && abs.endsWith(suffix)) {
|
|
1364
|
+
out.push(abs);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
};
|
|
1368
|
+
walk(dir);
|
|
1369
|
+
return out;
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Scan for files that extend kuratchiDO.
|
|
1373
|
+
* Primary discovery is recursive under `src/server` for files ending in `.do.ts`.
|
|
1374
|
+
* Legacy fallback keeps `src/durable-objects/*.ts` compatible.
|
|
1375
|
+
*/
|
|
1376
|
+
function discoverDoHandlers(srcDir, doConfig, ormDatabases) {
|
|
1377
|
+
const serverDir = path.join(srcDir, 'server');
|
|
1378
|
+
const legacyDir = path.join(srcDir, 'durable-objects');
|
|
1379
|
+
const serverDoFiles = discoverFilesWithSuffix(serverDir, '.do.ts');
|
|
1380
|
+
const legacyDoFiles = discoverFilesWithSuffix(legacyDir, '.ts');
|
|
1381
|
+
const discoveredFiles = Array.from(new Set([...serverDoFiles, ...legacyDoFiles]));
|
|
1382
|
+
if (discoveredFiles.length === 0)
|
|
1383
|
+
return [];
|
|
1384
|
+
const bindings = new Set(doConfig.map(d => d.binding));
|
|
1385
|
+
const fileToBinding = new Map();
|
|
1386
|
+
for (const entry of doConfig) {
|
|
1387
|
+
for (const rawFile of entry.files ?? []) {
|
|
1388
|
+
const normalized = rawFile.trim().replace(/^\.?[\\/]/, '').replace(/\\/g, '/').toLowerCase();
|
|
1389
|
+
if (!normalized)
|
|
1390
|
+
continue;
|
|
1391
|
+
fileToBinding.set(normalized, entry.binding);
|
|
1392
|
+
const base = path.basename(normalized);
|
|
1393
|
+
if (!fileToBinding.has(base))
|
|
1394
|
+
fileToBinding.set(base, entry.binding);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
const handlers = [];
|
|
1398
|
+
const fileNameToAbsPath = new Map();
|
|
1399
|
+
for (const absPath of discoveredFiles) {
|
|
1400
|
+
const file = path.basename(absPath);
|
|
1401
|
+
const source = fs.readFileSync(absPath, 'utf-8');
|
|
1402
|
+
// Check if this file extends kuratchiDO
|
|
1403
|
+
if (!/extends\s+kuratchiDO\b/.test(source))
|
|
1404
|
+
continue;
|
|
1405
|
+
// Extract class name: export default class Sites extends kuratchiDO
|
|
1406
|
+
const classMatch = source.match(/export\s+default\s+class\s+(\w+)\s+extends\s+kuratchiDO/);
|
|
1407
|
+
if (!classMatch)
|
|
1408
|
+
continue;
|
|
1409
|
+
const className = classMatch[1];
|
|
1410
|
+
// Binding resolution:
|
|
1411
|
+
// 1) explicit static binding in class
|
|
1412
|
+
// 2) config-mapped file name (supports .do.ts convention)
|
|
1413
|
+
// 3) if exactly one DO binding exists, infer that binding
|
|
1414
|
+
let binding = null;
|
|
1415
|
+
const bindingMatch = source.match(/static\s+binding\s*=\s*['"](\w+)['"]/);
|
|
1416
|
+
if (bindingMatch) {
|
|
1417
|
+
binding = bindingMatch[1];
|
|
1418
|
+
}
|
|
1419
|
+
else {
|
|
1420
|
+
const normalizedFile = file.replace(/\\/g, '/').toLowerCase();
|
|
1421
|
+
const normalizedRelFromSrc = path
|
|
1422
|
+
.relative(srcDir, absPath)
|
|
1423
|
+
.replace(/\\/g, '/')
|
|
1424
|
+
.toLowerCase();
|
|
1425
|
+
binding = fileToBinding.get(normalizedRelFromSrc) ?? fileToBinding.get(normalizedFile) ?? null;
|
|
1426
|
+
if (!binding && doConfig.length === 1) {
|
|
1427
|
+
binding = doConfig[0].binding;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
if (!binding)
|
|
1431
|
+
continue;
|
|
1432
|
+
if (!bindings.has(binding))
|
|
1433
|
+
continue;
|
|
1434
|
+
// Extract class methods — find the class body and parse method declarations
|
|
1435
|
+
const classMethods = extractClassMethods(source, className);
|
|
1436
|
+
// Extract named exports (custom worker-side helpers)
|
|
1437
|
+
const namedExports = extractNamedExports(source);
|
|
1438
|
+
const fileName = file.replace(/\.ts$/, '');
|
|
1439
|
+
const existing = fileNameToAbsPath.get(fileName);
|
|
1440
|
+
if (existing && existing !== absPath) {
|
|
1441
|
+
throw new Error(`[KuratchiJS] Duplicate DO handler file name '${fileName}.ts' detected:\n- ${existing}\n- ${absPath}\nRename one file or move it to avoid proxy name collision.`);
|
|
1442
|
+
}
|
|
1443
|
+
fileNameToAbsPath.set(fileName, absPath);
|
|
1444
|
+
handlers.push({
|
|
1445
|
+
fileName,
|
|
1446
|
+
absPath,
|
|
1447
|
+
binding,
|
|
1448
|
+
className,
|
|
1449
|
+
classMethods,
|
|
1450
|
+
namedExports,
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
return handlers;
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Extract method names from a class body using brace-balanced parsing.
|
|
1457
|
+
*/
|
|
1458
|
+
function extractClassMethods(source, className) {
|
|
1459
|
+
// Find: class ClassName extends kuratchiDO {
|
|
1460
|
+
const classIdx = source.search(new RegExp(`class\\s+${className}\\s+extends\\s+kuratchiDO`));
|
|
1461
|
+
if (classIdx === -1)
|
|
1462
|
+
return [];
|
|
1463
|
+
const braceStart = source.indexOf('{', classIdx);
|
|
1464
|
+
if (braceStart === -1)
|
|
1465
|
+
return [];
|
|
1466
|
+
// Balance braces to find end of class
|
|
1467
|
+
let depth = 0, braceEnd = braceStart;
|
|
1468
|
+
for (let i = braceStart; i < source.length; i++) {
|
|
1469
|
+
if (source[i] === '{')
|
|
1470
|
+
depth++;
|
|
1471
|
+
else if (source[i] === '}') {
|
|
1472
|
+
depth--;
|
|
1473
|
+
if (depth === 0) {
|
|
1474
|
+
braceEnd = i;
|
|
1475
|
+
break;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
const classBody = source.slice(braceStart + 1, braceEnd);
|
|
1480
|
+
// Match method declarations with optional visibility/static/async modifiers.
|
|
1481
|
+
const methods = [];
|
|
1482
|
+
const methodRegex = /^\s+(?:(public|private|protected)\s+)?(?:(static)\s+)?(?:(async)\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?::[^{]+)?\{/gm;
|
|
1483
|
+
const reserved = new Set([
|
|
1484
|
+
'constructor', 'static', 'get', 'set',
|
|
1485
|
+
'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'case',
|
|
1486
|
+
'throw', 'try', 'catch', 'finally', 'new', 'delete', 'typeof',
|
|
1487
|
+
'void', 'instanceof', 'in', 'of', 'await', 'yield', 'const',
|
|
1488
|
+
'let', 'var', 'function', 'class', 'import', 'export', 'default',
|
|
1489
|
+
'break', 'continue', 'with', 'super', 'this',
|
|
1490
|
+
]);
|
|
1491
|
+
let m;
|
|
1492
|
+
while ((m = methodRegex.exec(classBody)) !== null) {
|
|
1493
|
+
const visibility = m[1] ?? 'public';
|
|
1494
|
+
const isStatic = !!m[2];
|
|
1495
|
+
const isAsync = !!m[3];
|
|
1496
|
+
const name = m[4];
|
|
1497
|
+
if (isStatic)
|
|
1498
|
+
continue;
|
|
1499
|
+
if (reserved.has(name))
|
|
1500
|
+
continue;
|
|
1501
|
+
const matchText = m[0] ?? '';
|
|
1502
|
+
const openRel = matchText.lastIndexOf('{');
|
|
1503
|
+
const openAbs = openRel >= 0 ? m.index + openRel : -1;
|
|
1504
|
+
let hasWorkerContextCalls = false;
|
|
1505
|
+
const callsThisMethods = [];
|
|
1506
|
+
if (openAbs >= 0) {
|
|
1507
|
+
let depth = 0;
|
|
1508
|
+
let endAbs = openAbs;
|
|
1509
|
+
for (let i = openAbs; i < classBody.length; i++) {
|
|
1510
|
+
const ch = classBody[i];
|
|
1511
|
+
if (ch === '{')
|
|
1512
|
+
depth++;
|
|
1513
|
+
else if (ch === '}') {
|
|
1514
|
+
depth--;
|
|
1515
|
+
if (depth === 0) {
|
|
1516
|
+
endAbs = i;
|
|
1517
|
+
break;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
const bodySource = classBody.slice(openAbs + 1, endAbs);
|
|
1522
|
+
hasWorkerContextCalls = /\b(getCurrentUser|redirect|goto|getRequest|getLocals)\s*\(/.test(bodySource);
|
|
1523
|
+
const called = new Set();
|
|
1524
|
+
const callRegex = /\bthis\.([A-Za-z_$][\w$]*)\s*\(/g;
|
|
1525
|
+
let cm;
|
|
1526
|
+
while ((cm = callRegex.exec(bodySource)) !== null) {
|
|
1527
|
+
called.add(cm[1]);
|
|
1528
|
+
}
|
|
1529
|
+
callsThisMethods.push(...called);
|
|
1530
|
+
}
|
|
1531
|
+
methods.push({
|
|
1532
|
+
name,
|
|
1533
|
+
visibility: visibility,
|
|
1534
|
+
isStatic,
|
|
1535
|
+
isAsync,
|
|
1536
|
+
hasWorkerContextCalls,
|
|
1537
|
+
callsThisMethods,
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
return methods;
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Extract explicitly exported function/const names from a file.
|
|
1544
|
+
*/
|
|
1545
|
+
function extractNamedExports(source) {
|
|
1546
|
+
const exports = [];
|
|
1547
|
+
// export async function name / export function name
|
|
1548
|
+
const fnRegex = /export\s+(?:async\s+)?function\s+(\w+)/g;
|
|
1549
|
+
let m;
|
|
1550
|
+
while ((m = fnRegex.exec(source)) !== null)
|
|
1551
|
+
exports.push(m[1]);
|
|
1552
|
+
// export const name
|
|
1553
|
+
const constRegex = /export\s+const\s+(\w+)/g;
|
|
1554
|
+
while ((m = constRegex.exec(source)) !== null)
|
|
1555
|
+
exports.push(m[1]);
|
|
1556
|
+
return exports;
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Generate a proxy module for a DO handler file.
|
|
1560
|
+
*
|
|
1561
|
+
* The proxy provides:
|
|
1562
|
+
* - Auto-RPC function exports for each public class method
|
|
1563
|
+
* - Re-exports of the user's custom named exports
|
|
1564
|
+
*
|
|
1565
|
+
* Methods that collide with custom exports are skipped (user's export wins).
|
|
1566
|
+
*/
|
|
1567
|
+
function generateHandlerProxy(handler, projectDir) {
|
|
1568
|
+
const doDir = path.join(projectDir, '.kuratchi', 'do');
|
|
1569
|
+
const origRelPath = path.relative(doDir, handler.absPath).replace(/\\/g, '/').replace(/\.ts$/, '.js');
|
|
1570
|
+
const handlerLocal = `__handler_${toSafeIdentifier(handler.fileName)}`;
|
|
1571
|
+
const customSet = new Set(handler.namedExports);
|
|
1572
|
+
const methods = handler.classMethods.map((m) => ({ ...m }));
|
|
1573
|
+
const methodMap = new Map(methods.map((m) => [m.name, m]));
|
|
1574
|
+
let changed = true;
|
|
1575
|
+
while (changed) {
|
|
1576
|
+
changed = false;
|
|
1577
|
+
for (const m of methods) {
|
|
1578
|
+
if (m.hasWorkerContextCalls)
|
|
1579
|
+
continue;
|
|
1580
|
+
for (const called of m.callsThisMethods) {
|
|
1581
|
+
const target = methodMap.get(called);
|
|
1582
|
+
if (target?.hasWorkerContextCalls) {
|
|
1583
|
+
m.hasWorkerContextCalls = true;
|
|
1584
|
+
changed = true;
|
|
1585
|
+
break;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
const publicMethods = methods.filter((m) => m.visibility === 'public').map((m) => m.name);
|
|
1591
|
+
const workerContextMethods = methods
|
|
1592
|
+
.filter((m) => m.visibility === 'public' && m.hasWorkerContextCalls)
|
|
1593
|
+
.map((m) => m.name);
|
|
1594
|
+
const asyncMethods = methods.filter((m) => m.isAsync).map((m) => m.name);
|
|
1595
|
+
const lines = [
|
|
1596
|
+
`// Auto-generated by KuratchiJS compiler — do not edit.`,
|
|
1597
|
+
`import { __getDoStub } from '${RUNTIME_DO_IMPORT}';`,
|
|
1598
|
+
`import ${handlerLocal} from '${origRelPath}';`,
|
|
1599
|
+
``,
|
|
1600
|
+
];
|
|
1601
|
+
if (workerContextMethods.length > 0) {
|
|
1602
|
+
lines.push(`const __workerMethods = new Set(${JSON.stringify(workerContextMethods)});`);
|
|
1603
|
+
lines.push(`const __asyncMethods = new Set(${JSON.stringify(asyncMethods)});`);
|
|
1604
|
+
lines.push(`function __callWorkerMethod(__name, __args) {`);
|
|
1605
|
+
lines.push(` const __self = new Proxy({}, {`);
|
|
1606
|
+
lines.push(` get(_, __k) {`);
|
|
1607
|
+
lines.push(` if (typeof __k !== 'string') return undefined;`);
|
|
1608
|
+
lines.push(` if (__k === 'db') {`);
|
|
1609
|
+
lines.push(` throw new Error("[KuratchiJS] Worker-executed DO method cannot use this.db directly. Move DB access into a non-public method and call it via this.<method>().");`);
|
|
1610
|
+
lines.push(` }`);
|
|
1611
|
+
lines.push(` if (__workerMethods.has(__k)) {`);
|
|
1612
|
+
lines.push(` return (...__a) => ${handlerLocal}.prototype[__k].apply(__self, __a);`);
|
|
1613
|
+
lines.push(` }`);
|
|
1614
|
+
lines.push(` const __local = ${handlerLocal}.prototype[__k];`);
|
|
1615
|
+
lines.push(` if (typeof __local === 'function' && !__asyncMethods.has(__k)) {`);
|
|
1616
|
+
lines.push(` return (...__a) => __local.apply(__self, __a);`);
|
|
1617
|
+
lines.push(` }`);
|
|
1618
|
+
lines.push(` return async (...__a) => { const __s = await __getDoStub('${handler.binding}'); if (!__s) throw new Error('Not authenticated'); return __s[__k](...__a); };`);
|
|
1619
|
+
lines.push(` },`);
|
|
1620
|
+
lines.push(` });`);
|
|
1621
|
+
lines.push(` return ${handlerLocal}.prototype[__name].apply(__self, __args);`);
|
|
1622
|
+
lines.push(`}`);
|
|
1623
|
+
lines.push(``);
|
|
1624
|
+
}
|
|
1625
|
+
// Export class methods (skip if a custom export has the same name)
|
|
1626
|
+
for (const method of publicMethods) {
|
|
1627
|
+
if (customSet.has(method))
|
|
1628
|
+
continue; // user's export wins
|
|
1629
|
+
if (workerContextMethods.includes(method)) {
|
|
1630
|
+
lines.push(`export async function ${method}(...a) { return __callWorkerMethod('${method}', a); }`);
|
|
1631
|
+
}
|
|
1632
|
+
else {
|
|
1633
|
+
lines.push(`export async function ${method}(...a) { const s = await __getDoStub('${handler.binding}'); if (!s) throw new Error('Not authenticated'); return s.${method}(...a); }`);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
// Re-export custom named exports from the original file
|
|
1637
|
+
if (handler.namedExports.length > 0) {
|
|
1638
|
+
lines.push(``);
|
|
1639
|
+
lines.push(`export { ${handler.namedExports.join(', ')} } from '${origRelPath}';`);
|
|
1640
|
+
}
|
|
1641
|
+
return lines.join('\n') + '\n';
|
|
1642
|
+
}
|
|
1643
|
+
function generateRoutesModule(opts) {
|
|
1644
|
+
const layoutBlock = opts.compiledLayout ?? 'function __layout(content) { return content; }';
|
|
1645
|
+
const layoutActionsBlock = opts.compiledLayoutActions
|
|
1646
|
+
? `const __layoutActions = ${opts.compiledLayoutActions};`
|
|
1647
|
+
: 'const __layoutActions = {};';
|
|
1648
|
+
// Custom error page overrides (user-created NNN.html files)
|
|
1649
|
+
const customErrorFunctions = Array.from(opts.compiledErrorPages.entries())
|
|
1650
|
+
.map(([status, fn]) => fn)
|
|
1651
|
+
.join('\n\n');
|
|
1652
|
+
// Resolve path to the framework's context module from the output directory
|
|
1653
|
+
const contextImport = `import { __setRequestContext, __setEnvCompat, __esc, __setLocal, __getLocals, buildDefaultBreadcrumbs as __buildDefaultBreadcrumbs } from '${RUNTIME_CONTEXT_IMPORT}';`;
|
|
1654
|
+
// Auth session init — thin cookie parsing injected into Worker entry
|
|
1655
|
+
let authInit = '';
|
|
1656
|
+
if (opts.authConfig && opts.authConfig.sessionEnabled) {
|
|
1657
|
+
const cookieName = opts.authConfig.cookieName;
|
|
1658
|
+
authInit = `
|
|
1659
|
+
// ── Auth Session Init ───────────────────────────────────────
|
|
1660
|
+
|
|
1661
|
+
function __parseCookies(header) {
|
|
1662
|
+
const map = {};
|
|
1663
|
+
if (!header) return map;
|
|
1664
|
+
for (const pair of header.split(';')) {
|
|
1665
|
+
const eq = pair.indexOf('=');
|
|
1666
|
+
if (eq === -1) continue;
|
|
1667
|
+
map[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
|
|
1668
|
+
}
|
|
1669
|
+
return map;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
function __initAuth(request) {
|
|
1673
|
+
const cookies = __parseCookies(request.headers.get('cookie'));
|
|
1674
|
+
__setLocal('session', null);
|
|
1675
|
+
__setLocal('user', null);
|
|
1676
|
+
__setLocal('auth', {
|
|
1677
|
+
cookies,
|
|
1678
|
+
sessionCookie: cookies['${cookieName}'] || null,
|
|
1679
|
+
cookieName: '${cookieName}',
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
`;
|
|
1683
|
+
}
|
|
1684
|
+
const workerImport = `import { WorkerEntrypoint, env as __env } from 'cloudflare:workers';`;
|
|
1685
|
+
// ORM migration imports + init code
|
|
1686
|
+
let migrationImports = '';
|
|
1687
|
+
let migrationInit = '';
|
|
1688
|
+
if (opts.ormDatabases.length > 0) {
|
|
1689
|
+
const schemaImports = [];
|
|
1690
|
+
const migrateEntries = [];
|
|
1691
|
+
// Resolve schema import paths relative to .kuratchi output dir
|
|
1692
|
+
// Config imports are relative to project root (e.g., './src/schemas/todo')
|
|
1693
|
+
// Generated code lives in .kuratchi/routes.js, so we prefix ../ to reach project root
|
|
1694
|
+
for (const db of opts.ormDatabases) {
|
|
1695
|
+
const resolvedPath = db.schemaImportPath.replace(/^\.\//, '../');
|
|
1696
|
+
// Only D1 databases get runtime migration in the Worker fetch handler
|
|
1697
|
+
// DO databases are migrated via initDO() in the DO constructor
|
|
1698
|
+
if (!db.skipMigrations && db.type === 'd1') {
|
|
1699
|
+
schemaImports.push(`import { ${db.schemaExportName} } from '${resolvedPath}';`);
|
|
1700
|
+
migrateEntries.push(` { binding: '${db.binding}', schema: ${db.schemaExportName} }`);
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
if (migrateEntries.length > 0) {
|
|
1704
|
+
migrationImports = [
|
|
1705
|
+
`import { runMigrations } from '@kuratchi/orm/migrations';`,
|
|
1706
|
+
`import { kuratchiORM } from '@kuratchi/orm';`,
|
|
1707
|
+
...schemaImports,
|
|
1708
|
+
].join('\n');
|
|
1709
|
+
migrationInit = `
|
|
1710
|
+
// ── ORM Auto-Migration ──────────────────────────────────────
|
|
1711
|
+
|
|
1712
|
+
let __migrated = false;
|
|
1713
|
+
const __ormDatabases = [
|
|
1714
|
+
${migrateEntries.join(',\n')}
|
|
1715
|
+
];
|
|
1716
|
+
|
|
1717
|
+
async function __runMigrations() {
|
|
1718
|
+
if (__migrated) return;
|
|
1719
|
+
__migrated = true;
|
|
1720
|
+
for (const db of __ormDatabases) {
|
|
1721
|
+
const binding = __env[db.binding];
|
|
1722
|
+
if (!binding) continue;
|
|
1723
|
+
try {
|
|
1724
|
+
const executor = (sql, params) => {
|
|
1725
|
+
let stmt = binding.prepare(sql);
|
|
1726
|
+
if (params?.length) stmt = stmt.bind(...params);
|
|
1727
|
+
return stmt.all().then(r => ({ success: r.success ?? true, data: r.results, results: r.results }));
|
|
1728
|
+
};
|
|
1729
|
+
const result = await runMigrations({ execute: executor, schema: db.schema });
|
|
1730
|
+
if (result.applied) {
|
|
1731
|
+
console.log('[kuratchi] ' + db.binding + ': migrated (' + result.statementsRun + ' statements)');
|
|
1732
|
+
}
|
|
1733
|
+
if (result.warnings.length) {
|
|
1734
|
+
result.warnings.forEach(w => console.warn('[kuratchi] ' + db.binding + ': ' + w));
|
|
1735
|
+
}
|
|
1736
|
+
} catch (err) {
|
|
1737
|
+
console.error('[kuratchi] ' + db.binding + ' migration failed:', err.message);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
`;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
// Auth plugin init — import config + call @kuratchi/auth setup functions
|
|
1745
|
+
let authPluginImports = '';
|
|
1746
|
+
let authPluginInit = '';
|
|
1747
|
+
const ac = opts.authConfig;
|
|
1748
|
+
if (ac && (ac.hasCredentials || ac.hasActivity || ac.hasRoles || ac.hasOAuth || ac.hasGuards || ac.hasRateLimit || ac.hasTurnstile || ac.hasOrganization)) {
|
|
1749
|
+
const imports = [];
|
|
1750
|
+
const initLines = [];
|
|
1751
|
+
// Import the config file to read auth sub-configs at runtime
|
|
1752
|
+
imports.push(`import __kuratchiConfig from '../kuratchi.config';`);
|
|
1753
|
+
if (ac.hasCredentials) {
|
|
1754
|
+
imports.push(`import { configureCredentials as __configCreds } from '@kuratchi/auth';`);
|
|
1755
|
+
initLines.push(` if (__kuratchiConfig.auth?.credentials) __configCreds(__kuratchiConfig.auth.credentials);`);
|
|
1756
|
+
}
|
|
1757
|
+
if (ac.hasActivity) {
|
|
1758
|
+
imports.push(`import { defineActivities as __defActivities } from '@kuratchi/auth';`);
|
|
1759
|
+
initLines.push(` if (__kuratchiConfig.auth?.activity) __defActivities(__kuratchiConfig.auth.activity);`);
|
|
1760
|
+
}
|
|
1761
|
+
if (ac.hasRoles) {
|
|
1762
|
+
imports.push(`import { defineRoles as __defRoles } from '@kuratchi/auth';`);
|
|
1763
|
+
initLines.push(` if (__kuratchiConfig.auth?.roles) __defRoles(__kuratchiConfig.auth.roles);`);
|
|
1764
|
+
}
|
|
1765
|
+
if (ac.hasOAuth) {
|
|
1766
|
+
imports.push(`import { configureOAuth as __configOAuth } from '@kuratchi/auth';`);
|
|
1767
|
+
initLines.push(` if (__kuratchiConfig.auth?.oauth) {`);
|
|
1768
|
+
initLines.push(` const oc = __kuratchiConfig.auth.oauth;`);
|
|
1769
|
+
initLines.push(` const providers = {};`);
|
|
1770
|
+
initLines.push(` if (oc.providers) {`);
|
|
1771
|
+
initLines.push(` for (const [name, cfg] of Object.entries(oc.providers)) {`);
|
|
1772
|
+
initLines.push(` providers[name] = { clientId: __env[cfg.clientIdEnv] || '', clientSecret: __env[cfg.clientSecretEnv] || '', scopes: cfg.scopes };`);
|
|
1773
|
+
initLines.push(` }`);
|
|
1774
|
+
initLines.push(` }`);
|
|
1775
|
+
initLines.push(` __configOAuth({ providers, loginRedirect: oc.loginRedirect });`);
|
|
1776
|
+
initLines.push(` }`);
|
|
1777
|
+
}
|
|
1778
|
+
if (ac.hasGuards) {
|
|
1779
|
+
imports.push(`import { configureGuards as __configGuards, checkGuard as __checkGuard } from '@kuratchi/auth';`);
|
|
1780
|
+
initLines.push(` if (__kuratchiConfig.auth?.guards) __configGuards(__kuratchiConfig.auth.guards);`);
|
|
1781
|
+
}
|
|
1782
|
+
if (ac.hasRateLimit) {
|
|
1783
|
+
imports.push(`import { configureRateLimit as __configRL, checkRateLimit as __checkRL } from '@kuratchi/auth';`);
|
|
1784
|
+
initLines.push(` if (__kuratchiConfig.auth?.rateLimit) __configRL(__kuratchiConfig.auth.rateLimit);`);
|
|
1785
|
+
}
|
|
1786
|
+
if (ac.hasTurnstile) {
|
|
1787
|
+
imports.push(`import { configureTurnstile as __configTS, checkTurnstile as __checkTS } from '@kuratchi/auth';`);
|
|
1788
|
+
initLines.push(` if (__kuratchiConfig.auth?.turnstile) __configTS(__kuratchiConfig.auth.turnstile);`);
|
|
1789
|
+
}
|
|
1790
|
+
if (ac.hasOrganization) {
|
|
1791
|
+
imports.push(`import { configureOrganization as __configOrg } from '@kuratchi/auth';`);
|
|
1792
|
+
initLines.push(` if (__kuratchiConfig.auth?.organizations) __configOrg(__kuratchiConfig.auth.organizations);`);
|
|
1793
|
+
}
|
|
1794
|
+
authPluginImports = imports.join('\n');
|
|
1795
|
+
authPluginInit = `
|
|
1796
|
+
// ── Auth Plugin Init ───────────────────────────────────────
|
|
1797
|
+
|
|
1798
|
+
function __initAuthPlugins() {
|
|
1799
|
+
${initLines.join('\n')}
|
|
1800
|
+
}
|
|
1801
|
+
`;
|
|
1802
|
+
}
|
|
1803
|
+
// ── Durable Object class generation ───────────────────────────
|
|
1804
|
+
let doImports = '';
|
|
1805
|
+
let doClassCode = '';
|
|
1806
|
+
let doResolverInit = '';
|
|
1807
|
+
if (opts.doConfig.length > 0 && opts.doHandlers.length > 0) {
|
|
1808
|
+
const doImportLines = [];
|
|
1809
|
+
const doClassLines = [];
|
|
1810
|
+
const doResolverLines = [];
|
|
1811
|
+
doImportLines.push(`import { DurableObject as __DO } from 'cloudflare:workers';`);
|
|
1812
|
+
doImportLines.push(`import { initDO as __initDO } from '@kuratchi/orm';`);
|
|
1813
|
+
doImportLines.push(`import { __registerDoResolver, __registerDoClassBinding } from '${RUNTIME_DO_IMPORT}';`);
|
|
1814
|
+
// We need getCurrentUser and getOrgStubByName for stub resolvers
|
|
1815
|
+
doImportLines.push(`import { getCurrentUser as __getCU, getOrgStubByName as __getOSBN } from '@kuratchi/auth';`);
|
|
1816
|
+
// Group handlers by binding
|
|
1817
|
+
const handlersByBinding = new Map();
|
|
1818
|
+
for (const h of opts.doHandlers) {
|
|
1819
|
+
const list = handlersByBinding.get(h.binding) ?? [];
|
|
1820
|
+
list.push(h);
|
|
1821
|
+
handlersByBinding.set(h.binding, list);
|
|
1822
|
+
}
|
|
1823
|
+
// Import handler files + schema for each DO
|
|
1824
|
+
for (const doEntry of opts.doConfig) {
|
|
1825
|
+
const handlers = handlersByBinding.get(doEntry.binding) ?? [];
|
|
1826
|
+
const ormDb = opts.ormDatabases.find(d => d.binding === doEntry.binding);
|
|
1827
|
+
// Import schema (paths are relative to project root; prefix ../ since we're in .kuratchi/)
|
|
1828
|
+
if (ormDb) {
|
|
1829
|
+
const schemaPath = ormDb.schemaImportPath.replace(/^\.\//, '../');
|
|
1830
|
+
doImportLines.push(`import { ${ormDb.schemaExportName} as __doSchema_${doEntry.binding} } from '${schemaPath}';`);
|
|
1831
|
+
}
|
|
1832
|
+
// Import handler classes
|
|
1833
|
+
for (const h of handlers) {
|
|
1834
|
+
let handlerImportPath = path
|
|
1835
|
+
.relative(path.join(opts.projectDir, '.kuratchi'), h.absPath)
|
|
1836
|
+
.replace(/\\/g, '/')
|
|
1837
|
+
.replace(/\.ts$/, '.js');
|
|
1838
|
+
if (!handlerImportPath.startsWith('.'))
|
|
1839
|
+
handlerImportPath = './' + handlerImportPath;
|
|
1840
|
+
const handlerVar = `__handler_${toSafeIdentifier(h.fileName)}`;
|
|
1841
|
+
doImportLines.push(`import ${handlerVar} from '${handlerImportPath}';`);
|
|
1842
|
+
}
|
|
1843
|
+
// Generate DO class
|
|
1844
|
+
doClassLines.push(`export class ${doEntry.className} extends __DO {`);
|
|
1845
|
+
doClassLines.push(` constructor(ctx, env) {`);
|
|
1846
|
+
doClassLines.push(` super(ctx, env);`);
|
|
1847
|
+
if (ormDb) {
|
|
1848
|
+
doClassLines.push(` this.db = __initDO(ctx.storage.sql, __doSchema_${doEntry.binding});`);
|
|
1849
|
+
}
|
|
1850
|
+
doClassLines.push(` }`);
|
|
1851
|
+
if (ormDb) {
|
|
1852
|
+
doClassLines.push(` async __kuratchiLogActivity(payload) {`);
|
|
1853
|
+
doClassLines.push(` const now = new Date().toISOString();`);
|
|
1854
|
+
doClassLines.push(` try {`);
|
|
1855
|
+
doClassLines.push(` await this.db.activityLog.insert({`);
|
|
1856
|
+
doClassLines.push(` userId: payload?.userId ?? null,`);
|
|
1857
|
+
doClassLines.push(` action: payload?.action,`);
|
|
1858
|
+
doClassLines.push(` detail: payload?.detail ?? null,`);
|
|
1859
|
+
doClassLines.push(` ip: payload?.ip ?? null,`);
|
|
1860
|
+
doClassLines.push(` userAgent: payload?.userAgent ?? null,`);
|
|
1861
|
+
doClassLines.push(` createdAt: now,`);
|
|
1862
|
+
doClassLines.push(` updatedAt: now,`);
|
|
1863
|
+
doClassLines.push(` });`);
|
|
1864
|
+
doClassLines.push(` } catch (err) {`);
|
|
1865
|
+
doClassLines.push(` const msg = String((err && err.message) || err || '');`);
|
|
1866
|
+
doClassLines.push(` if (!msg.includes('userId')) throw err;`);
|
|
1867
|
+
doClassLines.push(` // Backward-compat fallback for org DBs not yet migrated with userId column.`);
|
|
1868
|
+
doClassLines.push(` await this.db.activityLog.insert({`);
|
|
1869
|
+
doClassLines.push(` action: payload?.action,`);
|
|
1870
|
+
doClassLines.push(` detail: payload?.detail ?? null,`);
|
|
1871
|
+
doClassLines.push(` ip: payload?.ip ?? null,`);
|
|
1872
|
+
doClassLines.push(` userAgent: payload?.userAgent ?? null,`);
|
|
1873
|
+
doClassLines.push(` createdAt: now,`);
|
|
1874
|
+
doClassLines.push(` updatedAt: now,`);
|
|
1875
|
+
doClassLines.push(` });`);
|
|
1876
|
+
doClassLines.push(` }`);
|
|
1877
|
+
doClassLines.push(` }`);
|
|
1878
|
+
doClassLines.push(` async __kuratchiGetActivity(options = {}) {`);
|
|
1879
|
+
doClassLines.push(` let query = this.db.activityLog;`);
|
|
1880
|
+
doClassLines.push(` if (options?.action) query = query.where({ action: options.action });`);
|
|
1881
|
+
doClassLines.push(` const result = await query.orderBy({ createdAt: 'desc' }).many();`);
|
|
1882
|
+
doClassLines.push(` const rows = Array.isArray(result?.data) ? result.data : [];`);
|
|
1883
|
+
doClassLines.push(` const limit = Number(options?.limit);`);
|
|
1884
|
+
doClassLines.push(` if (Number.isFinite(limit) && limit > 0) return rows.slice(0, Math.floor(limit));`);
|
|
1885
|
+
doClassLines.push(` return rows;`);
|
|
1886
|
+
doClassLines.push(` }`);
|
|
1887
|
+
}
|
|
1888
|
+
doClassLines.push(`}`);
|
|
1889
|
+
// Apply handler methods to prototype
|
|
1890
|
+
for (const h of handlers) {
|
|
1891
|
+
const handlerVar = `__handler_${toSafeIdentifier(h.fileName)}`;
|
|
1892
|
+
doClassLines.push(`for (const __k of Object.getOwnPropertyNames(${handlerVar}.prototype)) { if (__k !== 'constructor') ${doEntry.className}.prototype[__k] = ${handlerVar}.prototype[__k]; }`);
|
|
1893
|
+
doResolverLines.push(` __registerDoClassBinding(${handlerVar}, '${doEntry.binding}');`);
|
|
1894
|
+
}
|
|
1895
|
+
// Register stub resolver
|
|
1896
|
+
if (doEntry.stubId) {
|
|
1897
|
+
// Config-driven: e.g. stubId: 'user.orgId' → __u.orgId
|
|
1898
|
+
const fieldPath = doEntry.stubId.startsWith('user.') ? `__u.${doEntry.stubId.slice(5)}` : doEntry.stubId;
|
|
1899
|
+
const checkField = doEntry.stubId.startsWith('user.') ? doEntry.stubId.slice(5) : doEntry.stubId;
|
|
1900
|
+
doResolverLines.push(` __registerDoResolver('${doEntry.binding}', async () => {`);
|
|
1901
|
+
doResolverLines.push(` const __u = await __getCU();`);
|
|
1902
|
+
doResolverLines.push(` if (!__u?.${checkField}) return null;`);
|
|
1903
|
+
doResolverLines.push(` return __getOSBN(${fieldPath});`);
|
|
1904
|
+
doResolverLines.push(` });`);
|
|
1905
|
+
}
|
|
1906
|
+
else {
|
|
1907
|
+
// No stubId config — stub must be obtained manually
|
|
1908
|
+
doResolverLines.push(` // No 'stubId' config for ${doEntry.binding} — stub must be obtained manually`);
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
doImports = doImportLines.join('\n');
|
|
1912
|
+
doClassCode = `\n// ── Durable Object Classes (generated) ──────────────────────────\n\n` + doClassLines.join('\n') + '\n';
|
|
1913
|
+
doResolverInit = `\nfunction __initDoResolvers() {\n${doResolverLines.join('\n')}\n}\n`;
|
|
1914
|
+
}
|
|
1915
|
+
return `// Generated by KuratchiJS compiler — do not edit.
|
|
1916
|
+
${opts.isDev ? '\nglobalThis.__kuratchi_DEV__ = true;\n' : ''}
|
|
1917
|
+
${workerImport}
|
|
1918
|
+
${contextImport}
|
|
1919
|
+
${migrationImports ? migrationImports + '\n' : ''}${authPluginImports ? authPluginImports + '\n' : ''}${doImports ? doImports + '\n' : ''}${opts.serverImports.join('\n')}
|
|
1920
|
+
|
|
1921
|
+
// ── Assets ──────────────────────────────────────────────────────
|
|
1922
|
+
|
|
1923
|
+
const __assets = {
|
|
1924
|
+
${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')}
|
|
1925
|
+
};
|
|
1926
|
+
|
|
1927
|
+
// ── Router ──────────────────────────────────────────────────────
|
|
1928
|
+
|
|
1929
|
+
const __staticRoutes = new Map(); // exact path → index (O(1) lookup)
|
|
1930
|
+
const __dynamicRoutes = []; // regex-based routes (params/wildcards)
|
|
1931
|
+
|
|
1932
|
+
function __addRoute(pattern, index) {
|
|
1933
|
+
if (!pattern.includes(':') && !pattern.includes('*')) {
|
|
1934
|
+
// Static route — direct Map lookup, no regex needed
|
|
1935
|
+
__staticRoutes.set(pattern, index);
|
|
1936
|
+
} else {
|
|
1937
|
+
// Dynamic route — build regex for param extraction
|
|
1938
|
+
const paramNames = [];
|
|
1939
|
+
let regexStr = pattern
|
|
1940
|
+
.replace(/\\*(\\w+)/g, (_, name) => { paramNames.push(name); return '(?<' + name + '>.+)'; })
|
|
1941
|
+
.replace(/:(\\w+)/g, (_, name) => { paramNames.push(name); return '(?<' + name + '>[^/]+)'; });
|
|
1942
|
+
__dynamicRoutes.push({ regex: new RegExp('^' + regexStr + '$'), paramNames, index });
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
function __match(pathname) {
|
|
1947
|
+
const normalized = pathname === '/' ? '/' : pathname.replace(/\\/$/, '');
|
|
1948
|
+
// Fast path: static routes (most common)
|
|
1949
|
+
const staticIdx = __staticRoutes.get(normalized);
|
|
1950
|
+
if (staticIdx !== undefined) return { params: {}, index: staticIdx };
|
|
1951
|
+
// Slow path: dynamic routes with params
|
|
1952
|
+
for (const route of __dynamicRoutes) {
|
|
1953
|
+
const m = normalized.match(route.regex);
|
|
1954
|
+
if (m) {
|
|
1955
|
+
const params = {};
|
|
1956
|
+
for (const name of route.paramNames) params[name] = m.groups?.[name] ?? '';
|
|
1957
|
+
return { params, index: route.index };
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
return null;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// ── Layout ──────────────────────────────────────────────────────
|
|
1964
|
+
|
|
1965
|
+
${layoutBlock}
|
|
1966
|
+
|
|
1967
|
+
${layoutActionsBlock}
|
|
1968
|
+
|
|
1969
|
+
// ── Error pages ─────────────────────────────────────────────────
|
|
1970
|
+
|
|
1971
|
+
const __errorMessages = {
|
|
1972
|
+
400: 'Bad Request',
|
|
1973
|
+
401: 'Unauthorized',
|
|
1974
|
+
403: 'Forbidden',
|
|
1975
|
+
404: 'Not Found',
|
|
1976
|
+
405: 'Method Not Allowed',
|
|
1977
|
+
408: 'Request Timeout',
|
|
1978
|
+
429: 'Too Many Requests',
|
|
1979
|
+
500: 'Internal Server Error',
|
|
1980
|
+
502: 'Bad Gateway',
|
|
1981
|
+
503: 'Service Unavailable',
|
|
1982
|
+
};
|
|
1983
|
+
|
|
1984
|
+
// Built-in default error page — clean, dark, minimal, centered
|
|
1985
|
+
function __errorPage(status, detail) {
|
|
1986
|
+
const title = __errorMessages[status] || 'Error';
|
|
1987
|
+
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>' : '';
|
|
1988
|
+
return '<div style="display:flex;align-items:center;justify-content:center;min-height:60vh;text-align:center;padding:2rem">'
|
|
1989
|
+
+ '<div>'
|
|
1990
|
+
+ '<p style="font-size:5rem;font-weight:700;margin:0;color:#333;line-height:1">' + status + '</p>'
|
|
1991
|
+
+ '<p style="font-size:1rem;color:#555;margin:0.5rem 0 0;letter-spacing:0.05em">' + __esc(title) + '</p>'
|
|
1992
|
+
+ detailHtml
|
|
1993
|
+
+ '</div>'
|
|
1994
|
+
+ '</div>';
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
${customErrorFunctions ? '// Custom error page overrides (user-created NNN.html)\n' + customErrorFunctions + '\n' : ''}
|
|
1998
|
+
// Dispatch: use custom override if it exists, otherwise built-in default
|
|
1999
|
+
const __customErrors = {${Array.from(opts.compiledErrorPages.keys()).map(s => ` ${s}: __error_${s}`).join(',')} };
|
|
2000
|
+
|
|
2001
|
+
function __error(status, detail) {
|
|
2002
|
+
if (__customErrors[status]) return __customErrors[status](detail);
|
|
2003
|
+
return __errorPage(status, detail);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
${opts.compiledComponents.length > 0 ? '// ── Components ──────────────────────────────────────────────────\n\n' + opts.compiledComponents.join('\n\n') + '\n' : ''}${migrationInit}${authInit}${authPluginInit}${doResolverInit}${doClassCode}
|
|
2007
|
+
// ── Route definitions ───────────────────────────────────────────
|
|
2008
|
+
|
|
2009
|
+
const routes = [
|
|
2010
|
+
${opts.compiledRoutes.join(',\n')}
|
|
2011
|
+
];
|
|
2012
|
+
|
|
2013
|
+
for (let i = 0; i < routes.length; i++) __addRoute(routes[i].pattern, i);
|
|
2014
|
+
|
|
2015
|
+
// ── Response helpers ────────────────────────────────────────────
|
|
2016
|
+
|
|
2017
|
+
const __defaultSecHeaders = {
|
|
2018
|
+
'X-Content-Type-Options': 'nosniff',
|
|
2019
|
+
'X-Frame-Options': 'DENY',
|
|
2020
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
2021
|
+
};
|
|
2022
|
+
|
|
2023
|
+
function __secHeaders(response) {
|
|
2024
|
+
for (const [k, v] of Object.entries(__defaultSecHeaders)) {
|
|
2025
|
+
if (!response.headers.has(k)) response.headers.set(k, v);
|
|
2026
|
+
}
|
|
2027
|
+
return response;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
function __attachCookies(response) {
|
|
2031
|
+
const cookies = __getLocals().__setCookieHeaders;
|
|
2032
|
+
if (cookies && cookies.length > 0) {
|
|
2033
|
+
const newResponse = new Response(response.body, response);
|
|
2034
|
+
for (const h of cookies) newResponse.headers.append('Set-Cookie', h);
|
|
2035
|
+
return __secHeaders(newResponse);
|
|
2036
|
+
}
|
|
2037
|
+
return __secHeaders(response);
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
function __isSameOrigin(request, url) {
|
|
2041
|
+
const fetchSite = request.headers.get('sec-fetch-site');
|
|
2042
|
+
if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'same-site' && fetchSite !== 'none') {
|
|
2043
|
+
return false;
|
|
2044
|
+
}
|
|
2045
|
+
const origin = request.headers.get('origin');
|
|
2046
|
+
if (!origin) return true;
|
|
2047
|
+
try { return new URL(origin).origin === url.origin; } catch { return false; }
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
${opts.isLayoutAsync ? 'async ' : ''}function __render(route, data) {
|
|
2051
|
+
let html = route.render(data);
|
|
2052
|
+
const headMatch = html.match(/<head>([\\s\\S]*?)<\\/head>/);
|
|
2053
|
+
if (headMatch) {
|
|
2054
|
+
html = html.replace(headMatch[0], '');
|
|
2055
|
+
const layoutHtml = ${opts.isLayoutAsync ? 'await ' : ''}__layout(html);
|
|
2056
|
+
return __attachCookies(new Response(layoutHtml.replace('</head>', headMatch[1] + '</head>'), {
|
|
2057
|
+
headers: { 'content-type': 'text/html; charset=utf-8' }
|
|
2058
|
+
}));
|
|
2059
|
+
}
|
|
2060
|
+
return __attachCookies(new Response(${opts.isLayoutAsync ? 'await ' : ''}__layout(html), { headers: { 'content-type': 'text/html; charset=utf-8' } }));
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
// ── Exported Worker entrypoint ──────────────────────────────────
|
|
2064
|
+
|
|
2065
|
+
export default class extends WorkerEntrypoint {
|
|
2066
|
+
async fetch(request) {
|
|
2067
|
+
__setRequestContext(this.ctx, request);
|
|
2068
|
+
__setEnvCompat(this.env);
|
|
2069
|
+
${migrationInit ? ' await __runMigrations();\n' : ''}${authInit ? ' __initAuth(request);\n' : ''}${authPluginInit ? ' __initAuthPlugins();\n' : ''}${doResolverInit ? ' __initDoResolvers();\n' : ''}
|
|
2070
|
+
const url = new URL(request.url);
|
|
2071
|
+
${ac?.hasRateLimit ? '\n // Rate limiting — check before route handlers\n { const __rlRes = await __checkRL(); if (__rlRes) return __secHeaders(__rlRes); }\n' : ''}${ac?.hasTurnstile ? ' // Turnstile bot protection\n { const __tsRes = await __checkTS(); if (__tsRes) return __secHeaders(__tsRes); }\n' : ''}${ac?.hasGuards ? ' // Route guards — redirect if not authenticated\n { const __gRes = __checkGuard(); if (__gRes) return __secHeaders(__gRes); }\n' : ''}
|
|
2072
|
+
|
|
2073
|
+
// Serve static assets from src/assets/ at /_assets/*
|
|
2074
|
+
if (url.pathname.startsWith('/_assets/')) {
|
|
2075
|
+
const name = url.pathname.slice('/_assets/'.length);
|
|
2076
|
+
const asset = __assets[name];
|
|
2077
|
+
if (asset) {
|
|
2078
|
+
if (request.headers.get('if-none-match') === asset.etag) {
|
|
2079
|
+
return new Response(null, { status: 304 });
|
|
2080
|
+
}
|
|
2081
|
+
return new Response(asset.content, {
|
|
2082
|
+
headers: { 'content-type': asset.mime, 'cache-control': 'public, max-age=31536000, immutable', 'etag': asset.etag }
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
return __secHeaders(new Response('Not Found', { status: 404 }));
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
const match = __match(url.pathname);
|
|
2089
|
+
|
|
2090
|
+
if (!match) {
|
|
2091
|
+
return __secHeaders(new Response(${opts.isLayoutAsync ? 'await ' : ''}__layout(__error(404)), { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } }));
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
const route = routes[match.index];
|
|
2095
|
+
__setLocal('params', match.params);
|
|
2096
|
+
const __qFn = request.headers.get('x-kuratchi-query-fn') || '';
|
|
2097
|
+
const __qArgsRaw = request.headers.get('x-kuratchi-query-args') || '[]';
|
|
2098
|
+
let __qArgs = [];
|
|
2099
|
+
try {
|
|
2100
|
+
const __parsed = JSON.parse(__qArgsRaw);
|
|
2101
|
+
__qArgs = Array.isArray(__parsed) ? __parsed : [];
|
|
2102
|
+
} catch {}
|
|
2103
|
+
__setLocal('__queryOverride', __qFn ? { fn: __qFn, args: __qArgs } : null);
|
|
2104
|
+
if (!__getLocals().__breadcrumbs) {
|
|
2105
|
+
__setLocal('breadcrumbs', __buildDefaultBreadcrumbs(url.pathname, match.params));
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
// RPC call: GET ?_rpc=fnName&_args=[...] → JSON response
|
|
2109
|
+
const __rpcName = url.searchParams.get('_rpc');
|
|
2110
|
+
if (request.method === 'GET' && __rpcName && route.rpc?.[__rpcName]) {
|
|
2111
|
+
if (request.headers.get('x-kuratchi-rpc') !== '1') {
|
|
2112
|
+
return __secHeaders(new Response(JSON.stringify({ ok: false, error: 'Forbidden' }), {
|
|
2113
|
+
status: 403, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }
|
|
2114
|
+
}));
|
|
2115
|
+
}
|
|
2116
|
+
try {
|
|
2117
|
+
const __rpcArgsStr = url.searchParams.get('_args');
|
|
2118
|
+
let __rpcArgs = [];
|
|
2119
|
+
if (__rpcArgsStr) {
|
|
2120
|
+
const __parsed = JSON.parse(__rpcArgsStr);
|
|
2121
|
+
__rpcArgs = Array.isArray(__parsed) ? __parsed : [];
|
|
2122
|
+
}
|
|
2123
|
+
const __rpcResult = await route.rpc[__rpcName](...__rpcArgs);
|
|
2124
|
+
return __secHeaders(new Response(JSON.stringify({ ok: true, data: __rpcResult }), {
|
|
2125
|
+
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }
|
|
2126
|
+
}));
|
|
2127
|
+
} catch (err) {
|
|
2128
|
+
console.error('[kuratchi] RPC error:', err);
|
|
2129
|
+
const __errMsg = typeof __kuratchi_DEV__ !== 'undefined' ? err.message : 'Internal Server Error';
|
|
2130
|
+
return __secHeaders(new Response(JSON.stringify({ ok: false, error: __errMsg }), {
|
|
2131
|
+
status: 500, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }
|
|
2132
|
+
}));
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// Form action: POST with hidden _action field in form body
|
|
2137
|
+
if (request.method === 'POST') {
|
|
2138
|
+
if (!__isSameOrigin(request, url)) {
|
|
2139
|
+
return __secHeaders(new Response('Forbidden', { status: 403 }));
|
|
2140
|
+
}
|
|
2141
|
+
const formData = await request.formData();
|
|
2142
|
+
const actionName = formData.get('_action');
|
|
2143
|
+
const __actionFn = route.actions?.[actionName] || __layoutActions[actionName];
|
|
2144
|
+
if (actionName && __actionFn) {
|
|
2145
|
+
// Check if this is a fetch-based action call (onclick) with JSON args
|
|
2146
|
+
const argsStr = formData.get('_args');
|
|
2147
|
+
const isFetchAction = argsStr !== null;
|
|
2148
|
+
try {
|
|
2149
|
+
if (isFetchAction) {
|
|
2150
|
+
const __parsed = JSON.parse(argsStr);
|
|
2151
|
+
const args = Array.isArray(__parsed) ? __parsed : [];
|
|
2152
|
+
await __actionFn(...args);
|
|
2153
|
+
} else {
|
|
2154
|
+
await __actionFn(formData);
|
|
2155
|
+
}
|
|
2156
|
+
} catch (err) {
|
|
2157
|
+
console.error('[kuratchi] Action error:', err);
|
|
2158
|
+
if (isFetchAction) {
|
|
2159
|
+
const __errMsg = typeof __kuratchi_DEV__ !== 'undefined' ? err.message : 'Internal Server Error';
|
|
2160
|
+
return __secHeaders(new Response(JSON.stringify({ ok: false, error: __errMsg }), {
|
|
2161
|
+
status: 500, headers: { 'content-type': 'application/json' }
|
|
2162
|
+
}));
|
|
2163
|
+
}
|
|
2164
|
+
const __loaded = route.load ? await route.load(match.params) : {};
|
|
2165
|
+
const data = (__loaded && typeof __loaded === 'object') ? __loaded : { value: __loaded };
|
|
2166
|
+
data.params = match.params;
|
|
2167
|
+
data.breadcrumbs = __getLocals().__breadcrumbs ?? [];
|
|
2168
|
+
data.__error = (typeof __kuratchi_DEV__ !== 'undefined' && err && err.message) ? err.message : 'Action failed';
|
|
2169
|
+
return ${opts.isLayoutAsync ? 'await ' : ''}__render(route, data);
|
|
2170
|
+
}
|
|
2171
|
+
// Fetch-based actions return lightweight JSON (no page re-render)
|
|
2172
|
+
if (isFetchAction) {
|
|
2173
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
2174
|
+
headers: { 'content-type': 'application/json' }
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2177
|
+
// POST-Redirect-GET: redirect to custom target or back to same URL
|
|
2178
|
+
const __locals = __getLocals();
|
|
2179
|
+
const redirectTo = __locals.__redirectTo || url.pathname;
|
|
2180
|
+
const redirectStatus = Number(__locals.__redirectStatus) || 303;
|
|
2181
|
+
return __attachCookies(new Response(null, { status: redirectStatus, headers: { 'location': redirectTo } }));
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
// GET (or unmatched POST): load + render
|
|
2186
|
+
try {
|
|
2187
|
+
const __loaded = route.load ? await route.load(match.params) : {};
|
|
2188
|
+
const data = (__loaded && typeof __loaded === 'object') ? __loaded : { value: __loaded };
|
|
2189
|
+
data.params = match.params;
|
|
2190
|
+
data.breadcrumbs = __getLocals().__breadcrumbs ?? [];
|
|
2191
|
+
return ${opts.isLayoutAsync ? 'await ' : ''}__render(route, data);
|
|
2192
|
+
} catch (err) {
|
|
2193
|
+
console.error('[kuratchi] Route load/render error:', err);
|
|
2194
|
+
const __errDetail = typeof __kuratchi_DEV__ !== 'undefined' ? err.message : undefined;
|
|
2195
|
+
return __secHeaders(new Response(${opts.isLayoutAsync ? 'await ' : ''}__layout(__error(500, __errDetail)), { status: 500, headers: { 'content-type': 'text/html; charset=utf-8' } }));
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
`;
|
|
2200
|
+
}
|