@kuratchi/js 0.0.21 → 0.0.23

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.
@@ -0,0 +1,175 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ export function buildDesktopManifest(opts) {
4
+ const { projectDir, workerFile, desktopConfig, ormDatabases } = opts;
5
+ if (!desktopConfig)
6
+ return null;
7
+ const wranglerConfig = readDesktopWranglerConfig(projectDir);
8
+ const projectName = path.basename(projectDir);
9
+ const appName = desktopConfig.appName ?? projectName;
10
+ const appId = desktopConfig.appId ?? `dev.kuratchi.${projectName.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}`;
11
+ const assetsRoot = resolveAssetsRoot(projectDir);
12
+ const requestedRemoteBindings = desktopConfig.remoteBindings.length > 0
13
+ ? desktopConfig.remoteBindings
14
+ : ormDatabases
15
+ .filter((entry) => entry.type === 'd1' && entry.remote)
16
+ .map((entry) => ({ binding: entry.binding, type: 'd1', remote: true }));
17
+ const remoteBindings = requestedRemoteBindings.map((binding) => {
18
+ if (binding.type === 'd1') {
19
+ const wranglerBinding = wranglerConfig.d1Databases.find((entry) => entry.binding === binding.binding && entry.remote);
20
+ if (!wranglerBinding) {
21
+ throw new Error(`Desktop manifest could not resolve remote D1 binding "${binding.binding}" from wrangler.jsonc.`);
22
+ }
23
+ return {
24
+ binding: binding.binding,
25
+ type: 'd1',
26
+ remote: true,
27
+ databaseId: wranglerBinding.databaseId,
28
+ databaseName: wranglerBinding.databaseName,
29
+ };
30
+ }
31
+ const wranglerBinding = wranglerConfig.r2Buckets.find((entry) => entry.binding === binding.binding && entry.remote);
32
+ if (!wranglerBinding) {
33
+ throw new Error(`Desktop manifest could not resolve remote R2 binding "${binding.binding}" from wrangler.jsonc.`);
34
+ }
35
+ return {
36
+ binding: binding.binding,
37
+ type: 'r2',
38
+ remote: true,
39
+ bucketName: wranglerBinding.bucketName,
40
+ };
41
+ });
42
+ return {
43
+ formatVersion: 1,
44
+ generatedAt: new Date().toISOString(),
45
+ projectDir,
46
+ app: {
47
+ name: appName,
48
+ id: appId,
49
+ initialPath: desktopConfig.initialPath || '/',
50
+ window: {
51
+ title: desktopConfig.windowTitle ?? appName,
52
+ width: desktopConfig.windowWidth,
53
+ height: desktopConfig.windowHeight,
54
+ },
55
+ },
56
+ runtime: {
57
+ workerEntrypoint: workerFile,
58
+ assetsRoot,
59
+ compatibilityDate: wranglerConfig.compatibilityDate,
60
+ compatibilityFlags: wranglerConfig.compatibilityFlags,
61
+ cloudflareAccountId: wranglerConfig.cloudflareAccountId,
62
+ },
63
+ bindings: {
64
+ desktop: {
65
+ notifications: desktopConfig.bindings.notifications,
66
+ files: desktopConfig.bindings.files,
67
+ },
68
+ remote: remoteBindings,
69
+ },
70
+ };
71
+ }
72
+ export function writeDesktopManifest(projectDir, manifest) {
73
+ const outFile = path.join(projectDir, '.kuratchi', 'desktop.manifest.json');
74
+ const next = `${JSON.stringify(manifest, null, 2)}\n`;
75
+ if (fs.existsSync(outFile)) {
76
+ const current = fs.readFileSync(outFile, 'utf-8');
77
+ if (current === next)
78
+ return outFile;
79
+ }
80
+ fs.writeFileSync(outFile, next, 'utf-8');
81
+ return outFile;
82
+ }
83
+ function resolveAssetsRoot(projectDir) {
84
+ const publicDir = path.join(projectDir, '.kuratchi', 'public');
85
+ if (fs.existsSync(publicDir))
86
+ return publicDir;
87
+ const srcAssets = path.join(projectDir, 'src', 'assets');
88
+ if (fs.existsSync(srcAssets))
89
+ return srcAssets;
90
+ return null;
91
+ }
92
+ function readDesktopWranglerConfig(projectDir) {
93
+ const configPath = ['wrangler.jsonc', 'wrangler.json']
94
+ .map((name) => path.join(projectDir, name))
95
+ .find((candidate) => fs.existsSync(candidate));
96
+ if (!configPath) {
97
+ throw new Error('Desktop runtime requires wrangler.jsonc or wrangler.json for compatibility settings and remote bindings.');
98
+ }
99
+ const rawConfig = fs.readFileSync(configPath, 'utf-8');
100
+ const parsed = JSON.parse(stripJsonComments(rawConfig));
101
+ if (!parsed.compatibility_date) {
102
+ throw new Error(`Missing compatibility_date in ${path.basename(configPath)}.`);
103
+ }
104
+ return {
105
+ compatibilityDate: parsed.compatibility_date,
106
+ compatibilityFlags: Array.isArray(parsed.compatibility_flags) ? parsed.compatibility_flags : [],
107
+ cloudflareAccountId: typeof parsed.account_id === 'string' && parsed.account_id.trim().length > 0
108
+ ? parsed.account_id.trim()
109
+ : null,
110
+ d1Databases: Array.isArray(parsed.d1_databases)
111
+ ? parsed.d1_databases
112
+ .filter((entry) => entry.binding && entry.database_id)
113
+ .map((entry) => ({
114
+ binding: entry.binding,
115
+ databaseId: entry.database_id,
116
+ databaseName: entry.database_name ?? null,
117
+ remote: entry.remote !== false,
118
+ }))
119
+ : [],
120
+ r2Buckets: Array.isArray(parsed.r2_buckets)
121
+ ? parsed.r2_buckets
122
+ .filter((entry) => entry.binding && entry.bucket_name)
123
+ .map((entry) => ({
124
+ binding: entry.binding,
125
+ bucketName: entry.bucket_name,
126
+ remote: entry.remote !== false,
127
+ }))
128
+ : [],
129
+ };
130
+ }
131
+ function stripJsonComments(content) {
132
+ let result = '';
133
+ let i = 0;
134
+ let inString = false;
135
+ let stringChar = '';
136
+ while (i < content.length) {
137
+ const ch = content[i];
138
+ const next = content[i + 1];
139
+ if (inString) {
140
+ result += ch;
141
+ if (ch === '\\' && i + 1 < content.length) {
142
+ result += next;
143
+ i += 2;
144
+ continue;
145
+ }
146
+ if (ch === stringChar) {
147
+ inString = false;
148
+ }
149
+ i++;
150
+ continue;
151
+ }
152
+ if (ch === '"' || ch === '\'') {
153
+ inString = true;
154
+ stringChar = ch;
155
+ result += ch;
156
+ i++;
157
+ continue;
158
+ }
159
+ if (ch === '/' && next === '/') {
160
+ while (i < content.length && content[i] !== '\n')
161
+ i++;
162
+ continue;
163
+ }
164
+ if (ch === '/' && next === '*') {
165
+ i += 2;
166
+ while (i < content.length - 1 && !(content[i] === '*' && content[i + 1] === '/'))
167
+ i++;
168
+ i += 2;
169
+ continue;
170
+ }
171
+ result += ch;
172
+ i++;
173
+ }
174
+ return result;
175
+ }
@@ -1,6 +1,10 @@
1
- import { type DoConfigEntry, type DoHandlerEntry, type OrmDatabaseEntry } from './compiler-shared.js';
2
- export declare function discoverDurableObjects(srcDir: string, configDoEntries: DoConfigEntry[], ormDatabases: OrmDatabaseEntry[]): {
3
- config: DoConfigEntry[];
1
+ import { type DoHandlerEntry } from './compiler-shared.js';
2
+ export declare function discoverDurableObjects(srcDir: string): {
3
+ config: {
4
+ binding: string;
5
+ className: string;
6
+ files?: string[];
7
+ }[];
4
8
  handlers: DoHandlerEntry[];
5
9
  };
6
10
  export declare function generateHandlerProxy(handler: DoHandlerEntry, opts: {
@@ -3,27 +3,14 @@ import * as path from 'node:path';
3
3
  import * as ts from 'typescript';
4
4
  import { toSafeIdentifier, } from './compiler-shared.js';
5
5
  import { discoverFilesWithExtensions, discoverFilesWithSuffix } from './convention-discovery.js';
6
- export function discoverDurableObjects(srcDir, configDoEntries, ormDatabases) {
6
+ export function discoverDurableObjects(srcDir) {
7
7
  const serverDir = path.join(srcDir, 'server');
8
8
  const legacyDir = path.join(srcDir, 'durable-objects');
9
9
  const serverDoFiles = discoverFilesWithSuffix(serverDir, '.do.ts');
10
10
  const legacyDoFiles = discoverFilesWithSuffix(legacyDir, '.ts');
11
11
  const discoveredFiles = Array.from(new Set([...serverDoFiles, ...legacyDoFiles]));
12
12
  if (discoveredFiles.length === 0) {
13
- return { config: configDoEntries, handlers: [] };
14
- }
15
- const bindings = new Set(configDoEntries.map((d) => d.binding));
16
- const fileToBinding = new Map();
17
- for (const entry of configDoEntries) {
18
- for (const rawFile of entry.files ?? []) {
19
- const normalized = rawFile.trim().replace(/^\.?[\\/]/, '').replace(/\\/g, '/').toLowerCase();
20
- if (!normalized)
21
- continue;
22
- fileToBinding.set(normalized, entry.binding);
23
- const base = path.basename(normalized);
24
- if (!fileToBinding.has(base))
25
- fileToBinding.set(base, entry.binding);
26
- }
13
+ return { config: [], handlers: [] };
27
14
  }
28
15
  const handlers = [];
29
16
  const handlerIdToAbsPath = new Map();
@@ -48,28 +35,17 @@ export function discoverDurableObjects(srcDir, configDoEntries, ormDatabases) {
48
35
  continue;
49
36
  // Binding resolution:
50
37
  // 1) explicit static binding declared in the class
51
- // 2) config-mapped file name
52
- // 3) if exactly one binding exists, infer it
38
+ // 2) derive from the handler file name
53
39
  let binding = null;
54
40
  const bindingMatch = source.match(/static\s+binding\s*=\s*['"](\w+)['"]/);
55
41
  if (bindingMatch) {
56
42
  binding = bindingMatch[1];
57
43
  }
58
44
  else {
59
- const normalizedFile = file.replace(/\\/g, '/').toLowerCase();
60
- const normalizedRelFromSrc = path.relative(srcDir, absPath).replace(/\\/g, '/').toLowerCase();
61
- binding = className ? (configDoEntries.find((entry) => entry.className === className)?.binding ?? null) : null;
62
- if (!binding) {
63
- binding = fileToBinding.get(normalizedRelFromSrc) ?? fileToBinding.get(normalizedFile) ?? null;
64
- }
65
- if (!binding && configDoEntries.length === 1) {
66
- binding = configDoEntries[0].binding;
67
- }
45
+ binding = deriveBindingFromFile(file);
68
46
  }
69
47
  if (!binding)
70
48
  continue;
71
- if (!bindings.has(binding))
72
- continue;
73
49
  const classMethods = className ? extractClassMethods(absPath, source, className) : [];
74
50
  const fileName = path
75
51
  .relative(absPath.startsWith(serverDir) ? serverDir : legacyDir, absPath)
@@ -103,17 +79,14 @@ export function discoverDurableObjects(srcDir, configDoEntries, ormDatabases) {
103
79
  }
104
80
  }
105
81
  // Build config entries from discovered handlers (de-duped by binding).
106
- // Prefer class name from the original config entry (e.g. from wrangler.jsonc).
82
+ // The handler source is authoritative; wrangler.jsonc is then synced from this.
107
83
  const discoveredConfigByBinding = new Map();
108
84
  for (const handler of handlers) {
109
- const configEntry = configDoEntries.find((e) => e.binding === handler.binding);
110
85
  const existing = discoveredConfigByBinding.get(handler.binding);
111
86
  if (!existing) {
112
87
  discoveredConfigByBinding.set(handler.binding, {
113
88
  binding: handler.binding,
114
- // Use config class name when available (authoritative, e.g. from wrangler.jsonc).
115
- className: configEntry?.className ?? handler.className ?? handler.binding,
116
- stubId: configEntry?.stubId,
89
+ className: handler.className ?? deriveClassNameFromFile(path.basename(handler.absPath)),
117
90
  files: [path.basename(handler.absPath)],
118
91
  });
119
92
  }
@@ -121,9 +94,32 @@ export function discoverDurableObjects(srcDir, configDoEntries, ormDatabases) {
121
94
  existing.files?.push(path.basename(handler.absPath));
122
95
  }
123
96
  }
124
- void ormDatabases;
125
97
  return { config: [...discoveredConfigByBinding.values()], handlers };
126
98
  }
99
+ function deriveBindingFromFile(fileName) {
100
+ const normalized = fileName
101
+ .replace(/\.(ts|tsx|js|mjs|cjs)$/i, '')
102
+ .replace(/\.do$/i, '')
103
+ .replace(/[^A-Za-z0-9]+/g, '_')
104
+ .replace(/^_+|_+$/g, '')
105
+ .toUpperCase();
106
+ if (!normalized)
107
+ return 'DURABLE_OBJECT';
108
+ return normalized.endsWith('_DO') ? normalized : `${normalized}_DO`;
109
+ }
110
+ function deriveClassNameFromFile(fileName) {
111
+ const normalized = fileName
112
+ .replace(/\.(ts|tsx|js|mjs|cjs)$/i, '')
113
+ .replace(/\.do$/i, '');
114
+ const base = normalized
115
+ .split(/[^A-Za-z0-9]+/)
116
+ .filter(Boolean)
117
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
118
+ .join('');
119
+ if (!base)
120
+ return 'DurableObjectHandler';
121
+ return base.endsWith('DO') ? base : `${base}DO`;
122
+ }
127
123
  // ---------------------------------------------------------------------------
128
124
  // TypeScript AST helpers
129
125
  // ---------------------------------------------------------------------------
@@ -7,7 +7,7 @@ import { compileAssets } from './asset-pipeline.js';
7
7
  import { compileApiRoute } from './api-route-pipeline.js';
8
8
  import { createClientModuleCompiler } from './client-module-pipeline.js';
9
9
  import { createComponentCompiler } from './component-pipeline.js';
10
- import { readAssetsPrefix, readAuthConfig, readDoConfig, readOrmConfig, readSecurityConfig, readUiConfigValues, readUiTheme, } from './config-reading.js';
10
+ import { readAssetsPrefix, readAuthConfig, readOrmConfig, readSecurityConfig, readUiConfigValues, readUiTheme, } from './config-reading.js';
11
11
  import { discoverContainerFiles, discoverConventionClassFiles, discoverWorkflowFiles, } from './convention-discovery.js';
12
12
  import { discoverDurableObjects, generateHandlerProxy } from './durable-object-pipeline.js';
13
13
  import { compileErrorPages } from './error-page-pipeline.js';
@@ -132,12 +132,11 @@ export async function compile(options) {
132
132
  // Read security config from kuratchi.config.ts
133
133
  const securityConfig = readSecurityConfig(projectDir);
134
134
  // Auto-discover Durable Objects from .do.ts files (config optional, only needed for stubId)
135
- const configDoEntries = readDoConfig(projectDir);
136
- const { config: doConfig, handlers: doHandlers } = discoverDurableObjects(srcDir, configDoEntries, ormDatabases);
135
+ const { config: doConfig, handlers: doHandlers } = discoverDurableObjects(srcDir);
137
136
  // Auto-discover convention-based worker class files (no config needed)
138
137
  const containerConfig = discoverContainerFiles(projectDir);
139
138
  const workflowConfig = discoverWorkflowFiles(projectDir);
140
- const agentConfig = discoverConventionClassFiles(projectDir, path.join('src', 'server'), '.agent.ts', '.agent');
139
+ const agentConfig = discoverConventionClassFiles(projectDir, path.join('src', 'server'), '.agents.ts', '.agents');
141
140
  // Generate handler proxy modules in .kuratchi/do/ (must happen BEFORE route processing
142
141
  // so that $durable-objects/X imports can be redirected to the generated proxies)
143
142
  const doProxyDir = path.join(projectDir, '.kuratchi', 'do');
@@ -302,12 +301,18 @@ export async function compile(options) {
302
301
  // routes.ts already exports the default fetch handler and all named DO classes;
303
302
  // worker.ts explicitly re-exports them so wrangler.jsonc can reference a
304
303
  // stable filename while routes.ts is freely regenerated.
304
+ // If user has src/index.ts with a default export, merge it with the generated worker
305
+ // to support scheduled, queue, and other Cloudflare Worker handlers.
306
+ const userIndexFile = path.join(srcDir, 'index.ts');
307
+ const hasUserIndex = fs.existsSync(userIndexFile) &&
308
+ fs.readFileSync(userIndexFile, 'utf-8').includes('export default');
305
309
  const workerFile = path.join(outDir, 'worker.ts');
306
310
  writeIfChanged(workerFile, buildWorkerEntrypointSource({
307
311
  projectDir,
308
312
  outDir,
309
313
  doClassNames: doConfig.map((entry) => entry.className),
310
314
  workerClassEntries: [...agentConfig, ...containerConfig, ...workflowConfig],
315
+ hasUserIndex,
311
316
  }));
312
317
  writeIfChanged(path.join(outDir, 'worker.js'), buildCompatEntrypointSource('./worker.ts'));
313
318
  // Auto-sync wrangler.jsonc with workflow/container/DO config from kuratchi.config.ts
@@ -324,12 +329,17 @@ export async function compile(options) {
324
329
  copyDirIfChanged(srcAssetsDir, publicAssetsDir);
325
330
  syncedAssetsDirectory = path.relative(projectDir, publicDir).replace(/\\/g, '/');
326
331
  }
332
+ // Convert agent config to DO config format (agents are Durable Objects)
333
+ const agentDoConfig = agentConfig.map((entry) => {
334
+ const binding = entry.className.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase();
335
+ return { binding, className: entry.className };
336
+ });
327
337
  syncWranglerConfigPipeline({
328
338
  projectDir,
329
339
  config: {
330
340
  workflows: workflowConfig,
331
341
  containers: containerConfig,
332
- durableObjects: doConfig,
342
+ durableObjects: [...doConfig, ...agentDoConfig],
333
343
  assetsDirectory: syncedAssetsDirectory,
334
344
  },
335
345
  writeFile: writeIfChanged,
@@ -1135,6 +1135,7 @@ export function parseFile(source, options = {}) {
1135
1135
  const actionFunctions = [];
1136
1136
  const pollFunctions = [];
1137
1137
  const dataGetQueries = [];
1138
+ let warnedLegacyActionAttrs = false;
1138
1139
  for (const tag of templateTags) {
1139
1140
  if (tag.closing)
1140
1141
  continue;
@@ -1143,6 +1144,11 @@ export function parseFile(source, options = {}) {
1143
1144
  actionFunctions.push(actionExpr);
1144
1145
  }
1145
1146
  for (const [attrName, attrValue] of tag.attrs.entries()) {
1147
+ if (!warnedLegacyActionAttrs && (attrName === 'data-action' || attrName === 'data-args')) {
1148
+ warnedLegacyActionAttrs = true;
1149
+ console.warn(`[kuratchi] ${options.filePath || kind}: authored data-action/data-args are deprecated. ` +
1150
+ `Use data-post={fn(...)} or action={fn} instead.`);
1151
+ }
1146
1152
  if (/^on[A-Za-z]+$/i.test(attrName)) {
1147
1153
  const actionCall = extractCallExpression(attrValue);
1148
1154
  if (actionCall && !actionFunctions.includes(actionCall.fnName))
@@ -383,7 +383,11 @@ function buildDurableObjectBlock(opts) {
383
383
  doResolverLines.push(` });`);
384
384
  }
385
385
  else {
386
- doResolverLines.push(` // No 'stubId' config for ${doEntry.binding} - stub must be obtained manually`);
386
+ doResolverLines.push(` __registerDoResolver('${doEntry.binding}', async () => {`);
387
+ doResolverLines.push(` const __ns = __env['${doEntry.binding}'];`);
388
+ doResolverLines.push(` if (!__ns?.idFromName || !__ns?.get) return null;`);
389
+ doResolverLines.push(` return __ns.get(__ns.idFromName('global'));`);
390
+ doResolverLines.push(` });`);
387
391
  }
388
392
  }
389
393
  return {
@@ -37,6 +37,36 @@ const JS_CONTROL_PATTERNS = [
37
37
  function isJsControlLine(line) {
38
38
  return JS_CONTROL_PATTERNS.some(p => p.test(line));
39
39
  }
40
+ /** HTML boolean attributes that should be present or absent, never have a value */
41
+ const BOOLEAN_ATTRIBUTES = new Set([
42
+ 'disabled',
43
+ 'checked',
44
+ 'selected',
45
+ 'readonly',
46
+ 'required',
47
+ 'hidden',
48
+ 'open',
49
+ 'autofocus',
50
+ 'autoplay',
51
+ 'controls',
52
+ 'default',
53
+ 'defer',
54
+ 'formnovalidate',
55
+ 'inert',
56
+ 'ismap',
57
+ 'itemscope',
58
+ 'loop',
59
+ 'multiple',
60
+ 'muted',
61
+ 'nomodule',
62
+ 'novalidate',
63
+ 'playsinline',
64
+ 'reversed',
65
+ 'async',
66
+ ]);
67
+ function isBooleanAttribute(name) {
68
+ return BOOLEAN_ATTRIBUTES.has(name.toLowerCase());
69
+ }
40
70
  const FRAGMENT_OPEN_MARKER = '<!--__KURATCHI_FRAGMENT_OPEN:';
41
71
  const FRAGMENT_CLOSE_MARKER = '<!--__KURATCHI_FRAGMENT_CLOSE-->';
42
72
  export function splitTemplateRenderSections(template) {
@@ -1130,9 +1160,13 @@ function compileHtmlSegment(line, actionNames, rpcNameMap, options = {}) {
1130
1160
  pos = closeIdx + 1;
1131
1161
  continue;
1132
1162
  }
1133
- else if (attrName === 'disabled' || attrName === 'checked' || attrName === 'hidden' || attrName === 'readonly') {
1134
- // Boolean attributes: disabled={expr} → conditionally include
1135
- result += `"\${${inner} ? '' : undefined}"`;
1163
+ else if (isBooleanAttribute(attrName)) {
1164
+ // Boolean attributes: disabled={expr} conditionally include the attribute or omit entirely
1165
+ // Remove the trailing "attrName=" we already appended, we'll handle it with a ternary
1166
+ result = result.replace(new RegExp(`\\s*${attrName}=$`), '');
1167
+ result += `\${${inner} ? ' ${attrName}' : ''}`;
1168
+ pos = closeIdx + 1;
1169
+ continue;
1136
1170
  }
1137
1171
  else {
1138
1172
  // Regular attribute: value={expr} → value="escaped"
@@ -10,5 +10,6 @@ export declare function buildWorkerEntrypointSource(opts: {
10
10
  outDir: string;
11
11
  doClassNames: string[];
12
12
  workerClassEntries: WorkerClassExportEntry[];
13
+ hasUserIndex?: boolean;
13
14
  }): string;
14
15
  export declare function buildCompatEntrypointSource(targetFile: string): string;
@@ -29,13 +29,18 @@ export function buildWorkerEntrypointSource(opts) {
29
29
  }
30
30
  return `export { ${entry.className} } from '${importPath}';`;
31
31
  });
32
- return [
32
+ const lines = [
33
33
  '// Auto-generated by kuratchi — do not edit.',
34
- "export { default } from './routes.ts';",
35
- ...opts.doClassNames.map((className) => `export { ${className} } from './routes.ts';`),
36
- ...workerClassExports,
37
- '',
38
- ].join('\n');
34
+ ];
35
+ // If user has src/index.ts with a default export, merge it with the generated fetch handler
36
+ if (opts.hasUserIndex) {
37
+ lines.push("import __generatedWorker from './routes.ts';", "import __userWorker from '../src/index.ts';", '', '// Merge user worker exports (scheduled, queue, etc.) with generated fetch handler', 'export default {', ' ...__userWorker,', ' fetch: __generatedWorker.fetch,', '};');
38
+ }
39
+ else {
40
+ lines.push("export { default } from './routes.ts';");
41
+ }
42
+ lines.push(...opts.doClassNames.map((className) => `export { ${className} } from './routes.ts';`), ...workerClassExports, '');
43
+ return lines.join('\n');
39
44
  }
40
45
  export function buildCompatEntrypointSource(targetFile) {
41
46
  return [
@@ -1,5 +1,26 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
+ function nextMigrationTag(existing) {
4
+ const used = new Set(existing
5
+ .map((entry) => (entry && typeof entry.tag === 'string' ? entry.tag : null))
6
+ .filter(Boolean));
7
+ let maxNumeric = 0;
8
+ for (const tag of used) {
9
+ const match = /^v(\d+)$/i.exec(String(tag));
10
+ if (match) {
11
+ maxNumeric = Math.max(maxNumeric, Number(match[1]));
12
+ }
13
+ }
14
+ let candidate = `v${maxNumeric > 0 ? maxNumeric + 1 : 1}`;
15
+ if (!used.has(candidate))
16
+ return candidate;
17
+ let index = maxNumeric + 1;
18
+ while (used.has(candidate)) {
19
+ index += 1;
20
+ candidate = `v${index}`;
21
+ }
22
+ return candidate;
23
+ }
3
24
  function stripJsonComments(content) {
4
25
  let result = '';
5
26
  let i = 0;
@@ -169,8 +190,34 @@ export function syncWranglerConfig(opts) {
169
190
  changed = true;
170
191
  console.log(`[kuratchi] Added durable_object "${durableObject.binding}" to wrangler config`);
171
192
  }
193
+ else if (existing.class_name !== durableObject.className) {
194
+ existing.class_name = durableObject.className;
195
+ changed = true;
196
+ console.log(`[kuratchi] Updated durable_object "${durableObject.binding}" class_name to "${durableObject.className}"`);
197
+ }
172
198
  }
173
199
  wranglerConfig.durable_objects.bindings = existingBindings;
200
+ const existingMigrations = Array.isArray(wranglerConfig.migrations) ? wranglerConfig.migrations : [];
201
+ const knownClasses = new Set();
202
+ for (const migration of existingMigrations) {
203
+ const newClasses = Array.isArray(migration?.new_sqlite_classes) ? migration.new_sqlite_classes : [];
204
+ for (const className of newClasses) {
205
+ if (typeof className === 'string' && className)
206
+ knownClasses.add(className);
207
+ }
208
+ }
209
+ const missingClasses = opts.config.durableObjects
210
+ .map((entry) => entry.className)
211
+ .filter((className) => !knownClasses.has(className));
212
+ if (missingClasses.length > 0) {
213
+ existingMigrations.push({
214
+ tag: nextMigrationTag(existingMigrations),
215
+ new_sqlite_classes: missingClasses,
216
+ });
217
+ wranglerConfig.migrations = existingMigrations;
218
+ changed = true;
219
+ console.log(`[kuratchi] Added durable object migration for ${missingClasses.join(', ')}`);
220
+ }
174
221
  }
175
222
  if (opts.config.assetsDirectory !== undefined) {
176
223
  const existing = wranglerConfig.assets;