@kuratchi/js 0.0.14 → 0.0.15

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 CHANGED
@@ -349,14 +349,31 @@ Navigate to a URL on click (respects `http:`/`https:` only):
349
349
 
350
350
  ### `data-poll` — polling
351
351
 
352
- Refresh a section automatically on an interval (milliseconds):
352
+ Poll and update an element's content at a human-readable interval with automatic exponential backoff:
353
353
 
354
354
  ```html
355
- <div data-refresh="/status" data-poll="3000">
355
+ <div data-poll={getStatus(itemId)} data-interval="2s">
356
356
  {status}
357
357
  </div>
358
358
  ```
359
359
 
360
+ **How it works:**
361
+ 1. Client sends a fragment request with `x-kuratchi-fragment` header
362
+ 2. Server re-renders the route but returns **only the fragment's innerHTML** — not the full page
363
+ 3. Client swaps the element's content — minimal payload, no full page reload
364
+
365
+ This fragment-based architecture is the foundation for partial rendering and scales to Astro-style islands.
366
+
367
+ **Interval formats:**
368
+ - `2s` — 2 seconds
369
+ - `500ms` — 500 milliseconds
370
+ - `1m` — 1 minute
371
+ - Default: `30s` with exponential backoff (30s → 45s → 67s → ... capped at 5 minutes)
372
+
373
+ **Options:**
374
+ - `data-interval` — polling interval (human-readable, default `30s`)
375
+ - `data-backoff="false"` — disable exponential backoff
376
+
360
377
  ### `data-select-all` / `data-select-item` — checkbox groups
361
378
 
362
379
  Sync a "select all" checkbox with a group of item checkboxes:
@@ -397,79 +414,87 @@ Durable Object behavior is enabled by filename suffix.
397
414
  - Any file not ending in `.do.ts` is treated as a normal server module.
398
415
  - No required folder name. `src/server/auth.do.ts`, `src/server/foo/bar/sites.do.ts`, etc. all work.
399
416
 
400
- ### Function mode (recommended)
417
+ ### Writing a Durable Object
401
418
 
402
- Write plain exported functions in a `.do.ts` file. Exported functions become DO RPC methods.
403
- Use `this.db`, `this.env`, and `this.ctx` inside those functions.
419
+ Extend the native Cloudflare `DurableObject` class. Public methods automatically become RPC-accessible:
404
420
 
405
421
  ```ts
406
- // src/server/auth/auth.do.ts
407
- import { getCurrentUser, hashPassword } from '@kuratchi/auth';
408
- import { redirect } from '@kuratchi/js';
409
-
410
- async function randomPassword(length = 24): Promise<string> {
411
- const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
412
- const bytes = new Uint8Array(length);
413
- crypto.getRandomValues(bytes);
414
- let out = '';
415
- for (let i = 0; i < length; i++) out += alphabet[bytes[i] % alphabet.length];
416
- return out;
417
- }
422
+ // src/server/user.do.ts
423
+ import { DurableObject } from 'cloudflare:workers';
418
424
 
419
- export async function getOrgUsers() {
420
- const result = await this.db.users.orderBy({ createdAt: 'asc' }).many();
421
- return result.data ?? [];
422
- }
425
+ export default class UserDO extends DurableObject {
426
+ async getName() {
427
+ return await this.ctx.storage.get('name');
428
+ }
423
429
 
424
- export async function createOrgUser(formData: FormData) {
425
- const user = await getCurrentUser();
426
- if (!user?.orgId) throw new Error('Not authenticated');
430
+ async setName(name: string) {
431
+ this._validate(name);
432
+ await this.ctx.storage.put('name', name);
433
+ }
427
434
 
428
- const email = String(formData.get('email') ?? '').trim().toLowerCase();
429
- if (!email) throw new Error('Email is required');
435
+ // NOT RPC-accessible (underscore prefix)
436
+ _validate(name: string) {
437
+ if (!name) throw new Error('Name required');
438
+ }
430
439
 
431
- const passwordHash = await hashPassword(await randomPassword(), undefined, this.env.AUTH_SECRET);
432
- await this.db.users.insert({ email, role: 'member', passwordHash });
433
- redirect('/settings/users');
440
+ // NOT RPC-accessible (lifecycle method)
441
+ async alarm() {
442
+ // Handle alarm
443
+ }
434
444
  }
435
445
  ```
436
446
 
437
- Optional lifecycle exports in function mode:
447
+ **RPC rules:**
448
+ - **Public methods** (`getName`, `setName`) → RPC-accessible
449
+ - **Underscore prefix** (`_validate`) → NOT RPC-accessible
450
+ - **Private/protected** (`private foo()`) → NOT RPC-accessible
451
+ - **Lifecycle methods** (`constructor`, `fetch`, `alarm`, `webSocketMessage`, etc.) → NOT RPC-accessible
438
452
 
439
- - `export async function onInit()`
440
- - `export async function onAlarm(...args)`
441
- - `export function onMessage(...args)`
453
+ ### Using from routes
442
454
 
443
- These lifecycle names are not exposed as RPC methods.
455
+ Import from `$do/<filename>` (without the `.do` suffix):
444
456
 
445
- ### Class mode (optional)
457
+ ```html
458
+ <script server>
459
+ import { getName, setName } from '$do/user';
446
460
 
447
- Class-based handlers are still supported in `.do.ts` files:
461
+ const name = await getName();
462
+ </script>
448
463
 
449
- ```ts
450
- import { kuratchiDO } from '@kuratchi/js';
464
+ <h1>Hello, {name}</h1>
465
+ ```
451
466
 
452
- export default class NotesDO extends kuratchiDO {
453
- static binding = 'NOTES_DO';
467
+ The framework handles RPC wiring automatically.
454
468
 
455
- async getNotes() {
456
- return (await this.db.notes.orderBy({ created_at: 'desc' }).many()).data ?? [];
457
- }
469
+ ### Auto-Discovery
470
+
471
+ Durable Objects are auto-discovered from `.do.ts` files. **No config needed.**
472
+
473
+ **Naming convention:**
474
+ - `user.do.ts` → binding `USER_DO`
475
+ - `org-settings.do.ts` → binding `ORG_SETTINGS_DO`
476
+
477
+ **Override binding name** with `static binding`:
478
+ ```ts
479
+ export default class UserDO extends DurableObject {
480
+ static binding = 'CUSTOM_BINDING'; // Optional override
481
+ // ...
458
482
  }
459
483
  ```
460
484
 
461
- Declare it in `kuratchi.config.ts` and in `wrangler.jsonc`. The compiler exports DO classes from `.kuratchi/worker.js` automatically.
485
+ The framework auto-syncs discovered DOs to `wrangler.jsonc`.
462
486
 
463
- ```jsonc
464
- // wrangler.jsonc
465
- {
466
- "durable_objects": {
467
- "bindings": [{ "name": "NOTES_DO", "class_name": "NotesDO" }]
487
+ ### Optional: stubId for auth integration
488
+
489
+ If you need automatic stub resolution based on user context, add `stubId` in `kuratchi.config.ts`:
490
+
491
+ ```ts
492
+ // kuratchi.config.ts
493
+ export default defineConfig({
494
+ durableObjects: {
495
+ USER_DO: { stubId: 'user.orgId' }, // Only needed for auth integration
468
496
  },
469
- "migrations": [
470
- { "tag": "v1", "new_sqlite_classes": ["NotesDO"] }
471
- ]
472
- }
497
+ });
473
498
  ```
474
499
 
475
500
  ## Agents
@@ -542,6 +567,46 @@ Examples:
542
567
  - `bond.workflow.ts` → `BOND_WORKFLOW` binding
543
568
  - `new-site.workflow.ts` → `NEW_SITE_WORKFLOW` binding
544
569
 
570
+ ### Workflow Status Polling
571
+
572
+ Kuratchi auto-generates status polling RPCs for each discovered workflow. Poll workflow status with zero setup:
573
+
574
+ ```html
575
+ <div data-poll={migrationWorkflowStatus(instanceId)} data-interval="2s">
576
+ if (workflowStatus.status === 'running') {
577
+ <div class="spinner">Running...</div>
578
+ } else if (workflowStatus.status === 'complete') {
579
+ <div>✓ Complete</div>
580
+ }
581
+ </div>
582
+ ```
583
+
584
+ The element's innerHTML updates automatically when the workflow status changes — no page reload needed.
585
+
586
+ **Auto-generated RPC naming** (camelCase):
587
+ - `migration.workflow.ts` → `migrationWorkflowStatus(instanceId)`
588
+ - `james-bond.workflow.ts` → `jamesBondWorkflowStatus(instanceId)`
589
+ - `site.workflow.ts` → `siteWorkflowStatus(instanceId)`
590
+
591
+ **Multiple workflows on one page:** Each `data-poll` element is independent. You can poll multiple workflow instances without collision:
592
+
593
+ ```html
594
+ for (const job of jobs) {
595
+ <div data-poll={migrationWorkflowStatus(job.instanceId)} data-interval="2s">
596
+ {job.name}: polling...
597
+ </div>
598
+ }
599
+ ```
600
+
601
+ The status RPC returns the Cloudflare `InstanceStatus` object:
602
+ ```ts
603
+ {
604
+ status: 'queued' | 'running' | 'paused' | 'errored' | 'terminated' | 'complete' | 'waiting' | 'unknown';
605
+ error?: { name: string; message: string; };
606
+ output?: unknown;
607
+ }
608
+ ```
609
+
545
610
  ## Containers
546
611
 
547
612
  Kuratchi auto-discovers `.container.ts` files in `src/server/`. **No config needed.**
@@ -701,7 +766,9 @@ Kuratchi also exposes a framework build-mode flag:
701
766
 
702
767
  ## `kuratchi.config.ts`
703
768
 
704
- Optional. Required only when using framework integrations or Durable Objects.
769
+ Optional. Required only when using framework integrations (ORM, auth, UI).
770
+
771
+ **Durable Objects are auto-discovered** — no config needed unless you need `stubId` for auth integration.
705
772
 
706
773
  ```ts
707
774
  import { defineConfig } from '@kuratchi/js';
@@ -717,16 +784,14 @@ export default defineConfig({
717
784
  NOTES_DO: { schema: notesSchema, type: 'do' },
718
785
  },
719
786
  }),
720
- durableObjects: {
721
- NOTES_DO: {
722
- className: 'NotesDO',
723
- files: ['notes.do.ts'],
724
- },
725
- },
726
787
  auth: kuratchiAuthConfig({
727
788
  cookieName: 'kuratchi_session',
728
789
  sessionEnabled: true,
729
790
  }),
791
+ // Optional: only needed for auth-based stub resolution
792
+ durableObjects: {
793
+ NOTES_DO: { stubId: 'user.orgId' },
794
+ },
730
795
  });
731
796
  ```
732
797
 
@@ -469,31 +469,47 @@ export function compile(options) {
469
469
  if(typeof dialog.showModal === 'function') dialog.showModal();
470
470
  }, true);
471
471
  (function initPoll(){
472
- var prev = {};
472
+ // Parse human-readable interval: 2s, 500ms, 1m, 30s (default 30s)
473
+ function parseInterval(str){
474
+ if(!str) return 30000;
475
+ var m = str.match(/^(\d+(?:\.\d+)?)(ms|s|m)?$/i);
476
+ if(!m) return 30000;
477
+ var n = parseFloat(m[1]);
478
+ var u = (m[2] || 's').toLowerCase();
479
+ if(u === 'ms') return n;
480
+ if(u === 'm') return n * 60000;
481
+ return n * 1000;
482
+ }
473
483
  function bindPollEl(el){
474
484
  if(!el || !el.getAttribute) return;
475
485
  if(el.getAttribute('data-kuratchi-poll-bound') === '1') return;
476
486
  var fn = el.getAttribute('data-poll');
477
487
  if(!fn) return;
478
488
  el.setAttribute('data-kuratchi-poll-bound', '1');
479
- var args = el.getAttribute('data-poll-args') || '[]';
480
- var iv = parseInt(el.getAttribute('data-interval') || '', 10) || 3000;
481
- var key = String(fn) + args;
482
- if(!(key in prev)) prev[key] = null;
489
+ var pollId = el.getAttribute('data-poll-id');
490
+ if(!pollId) return; // Server must provide stable poll ID
491
+ var baseIv = parseInterval(el.getAttribute('data-interval'));
492
+ var maxIv = Math.min(baseIv * 10, 300000); // cap at 5 minutes
493
+ var backoff = el.getAttribute('data-backoff') !== 'false';
494
+ var prevHtml = el.innerHTML;
495
+ var currentIv = baseIv;
483
496
  (function tick(){
484
497
  setTimeout(function(){
485
- fetch(location.pathname + '?_rpc=' + encodeURIComponent(String(fn)) + '&_args=' + encodeURIComponent(args), { headers: { 'x-kuratchi-rpc': '1' } })
486
- .then(function(r){ return r.json(); })
487
- .then(function(j){
488
- if(j && j.ok){
489
- var s = JSON.stringify(j.data);
490
- if(prev[key] !== null && prev[key] !== s){ location.reload(); return; }
491
- prev[key] = s;
498
+ // Request only the fragment, not the full page
499
+ fetch(location.pathname + location.search, { headers: { 'x-kuratchi-fragment': pollId } })
500
+ .then(function(r){ return r.text(); })
501
+ .then(function(html){
502
+ if(prevHtml !== html){
503
+ el.innerHTML = html;
504
+ prevHtml = html;
505
+ currentIv = baseIv; // Reset backoff on change
506
+ } else if(backoff && currentIv < maxIv){
507
+ currentIv = Math.min(currentIv * 1.5, maxIv);
492
508
  }
493
509
  tick();
494
510
  })
495
- .catch(function(){ tick(); });
496
- }, iv);
511
+ .catch(function(){ currentIv = baseIv; tick(); });
512
+ }, currentIv);
497
513
  })();
498
514
  }
499
515
  function scan(){
@@ -731,15 +747,13 @@ export function compile(options) {
731
747
  const ormDatabases = readOrmConfig(projectDir);
732
748
  // Read auth config from kuratchi.config.ts
733
749
  const authConfig = readAuthConfig(projectDir);
734
- // Read Durable Object config and discover handler files
735
- const doConfig = readDoConfig(projectDir);
750
+ // Auto-discover Durable Objects from .do.ts files (config optional, only needed for stubId)
751
+ const configDoEntries = readDoConfig(projectDir);
752
+ const { config: doConfig, handlers: doHandlers } = discoverDurableObjects(srcDir, configDoEntries, ormDatabases);
736
753
  // Auto-discover convention-based worker class files (no config needed)
737
754
  const containerConfig = discoverContainerFiles(projectDir);
738
755
  const workflowConfig = discoverWorkflowFiles(projectDir);
739
756
  const agentConfig = discoverConventionClassFiles(projectDir, path.join('src', 'server'), '.agent.ts', '.agent');
740
- const doHandlers = doConfig.length > 0
741
- ? discoverDoHandlers(srcDir, doConfig, ormDatabases)
742
- : [];
743
757
  // Generate handler proxy modules in .kuratchi/do/ (must happen BEFORE route processing
744
758
  // so that $durable-objects/X imports can be redirected to the generated proxies)
745
759
  const doProxyDir = path.join(projectDir, '.kuratchi', 'do');
@@ -1260,6 +1274,7 @@ export function compile(options) {
1260
1274
  authConfig,
1261
1275
  doConfig,
1262
1276
  doHandlers,
1277
+ workflowConfig,
1263
1278
  isDev: options.isDev ?? false,
1264
1279
  isLayoutAsync,
1265
1280
  compiledLayoutActions,
@@ -2153,95 +2168,89 @@ function discoverContainerFiles(projectDir) {
2153
2168
  });
2154
2169
  }
2155
2170
  /**
2156
- * Scan DO handler files.
2157
- * - Class mode: default class extends kuratchiDO
2158
- * - Function mode: exported functions in *.do.ts files (compiler wraps into DO class)
2171
+ * Auto-discover Durable Objects from .do.ts files.
2172
+ * Returns both DoConfigEntry (for wrangler sync) and DoHandlerEntry (for code gen).
2173
+ *
2174
+ * Convention:
2175
+ * - File: user.do.ts → Binding: USER_DO
2176
+ * - Class: export default class UserDO extends DurableObject
2177
+ * - Optional: static binding = 'CUSTOM_BINDING' to override
2159
2178
  */
2160
- function discoverDoHandlers(srcDir, doConfig, ormDatabases) {
2179
+ function discoverDurableObjects(srcDir, configDoEntries, ormDatabases) {
2161
2180
  const serverDir = path.join(srcDir, 'server');
2162
2181
  const legacyDir = path.join(srcDir, 'durable-objects');
2163
2182
  const serverDoFiles = discoverFilesWithSuffix(serverDir, '.do.ts');
2164
2183
  const legacyDoFiles = discoverFilesWithSuffix(legacyDir, '.ts');
2165
2184
  const discoveredFiles = Array.from(new Set([...serverDoFiles, ...legacyDoFiles]));
2166
- if (discoveredFiles.length === 0)
2167
- return [];
2168
- const bindings = new Set(doConfig.map(d => d.binding));
2169
- const fileToBinding = new Map();
2170
- for (const entry of doConfig) {
2171
- for (const rawFile of entry.files ?? []) {
2172
- const normalized = rawFile.trim().replace(/^\.?[\\/]/, '').replace(/\\/g, '/').toLowerCase();
2173
- if (!normalized)
2174
- continue;
2175
- fileToBinding.set(normalized, entry.binding);
2176
- const base = path.basename(normalized);
2177
- if (!fileToBinding.has(base))
2178
- fileToBinding.set(base, entry.binding);
2179
- }
2185
+ if (discoveredFiles.length === 0) {
2186
+ return { config: configDoEntries, handlers: [] };
2187
+ }
2188
+ // Build lookup from config for stubId (still needed for auth integration)
2189
+ const configByBinding = new Map();
2190
+ for (const entry of configDoEntries) {
2191
+ configByBinding.set(entry.binding, entry);
2180
2192
  }
2181
2193
  const handlers = [];
2194
+ const discoveredConfig = [];
2182
2195
  const fileNameToAbsPath = new Map();
2196
+ const seenBindings = new Set();
2183
2197
  for (const absPath of discoveredFiles) {
2184
2198
  const file = path.basename(absPath);
2185
2199
  const source = fs.readFileSync(absPath, 'utf-8');
2186
- const exportedFunctions = extractExportedFunctions(source);
2187
- const hasClass = /extends\s+kuratchiDO\b/.test(source);
2188
- if (!hasClass && exportedFunctions.length === 0)
2200
+ // Must extend DurableObject
2201
+ const hasClass = /extends\s+DurableObject\b/.test(source);
2202
+ if (!hasClass)
2189
2203
  continue;
2190
- // Extract class name when class mode is used.
2191
- const classMatch = source.match(/export\s+default\s+class\s+(\w+)\s+extends\s+kuratchiDO/);
2204
+ // Extract class name
2205
+ const classMatch = source.match(/export\s+default\s+class\s+(\w+)\s+extends\s+DurableObject/);
2192
2206
  const className = classMatch?.[1] ?? null;
2193
- if (hasClass && !className)
2207
+ if (!className)
2194
2208
  continue;
2195
- // Binding resolution:
2196
- // 1) explicit static binding in class
2197
- // 2) config-mapped file name (supports .do.ts convention)
2198
- // 3) if exactly one DO binding exists, infer that binding
2199
- let binding = null;
2209
+ // Derive binding from filename or static binding property
2210
+ // user.do.ts USER_DO
2200
2211
  const bindingMatch = source.match(/static\s+binding\s*=\s*['"](\w+)['"]/);
2201
- if (bindingMatch) {
2202
- binding = bindingMatch[1];
2203
- }
2204
- else {
2205
- const normalizedFile = file.replace(/\\/g, '/').toLowerCase();
2206
- const normalizedRelFromSrc = path
2207
- .relative(srcDir, absPath)
2208
- .replace(/\\/g, '/')
2209
- .toLowerCase();
2210
- binding = fileToBinding.get(normalizedRelFromSrc) ?? fileToBinding.get(normalizedFile) ?? null;
2211
- if (!binding && doConfig.length === 1) {
2212
- binding = doConfig[0].binding;
2213
- }
2214
- }
2215
- if (!binding)
2216
- continue;
2217
- if (!bindings.has(binding))
2218
- continue;
2219
- // Extract class methods in class mode
2220
- const classMethods = className ? extractClassMethods(source, className) : [];
2212
+ const baseName = file.replace(/\.do\.ts$/, '').replace(/\.ts$/, '');
2213
+ const derivedBinding = baseName.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase() + '_DO';
2214
+ const binding = bindingMatch?.[1] ?? derivedBinding;
2215
+ if (seenBindings.has(binding)) {
2216
+ throw new Error(`[KuratchiJS] Duplicate DO binding '${binding}' detected. Use 'static binding = "UNIQUE_NAME"' in one of the classes.`);
2217
+ }
2218
+ seenBindings.add(binding);
2219
+ // Extract public class methods for RPC
2220
+ const classMethods = extractClassMethods(source, className);
2221
2221
  const fileName = file.replace(/\.ts$/, '');
2222
2222
  const existing = fileNameToAbsPath.get(fileName);
2223
2223
  if (existing && existing !== absPath) {
2224
2224
  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.`);
2225
2225
  }
2226
2226
  fileNameToAbsPath.set(fileName, absPath);
2227
+ // Merge with config entry if exists (for stubId)
2228
+ const configEntry = configByBinding.get(binding);
2229
+ discoveredConfig.push({
2230
+ binding,
2231
+ className,
2232
+ stubId: configEntry?.stubId,
2233
+ files: [file],
2234
+ });
2227
2235
  handlers.push({
2228
2236
  fileName,
2229
2237
  absPath,
2230
2238
  binding,
2231
- mode: hasClass ? 'class' : 'function',
2232
- className: className ?? undefined,
2239
+ mode: 'class',
2240
+ className,
2233
2241
  classMethods,
2234
- exportedFunctions,
2242
+ exportedFunctions: [],
2235
2243
  });
2236
2244
  }
2237
- return handlers;
2245
+ return { config: discoveredConfig, handlers };
2238
2246
  }
2239
2247
  /**
2240
2248
  * Extract method names from a class body using brace-balanced parsing.
2249
+ * Only public methods (no private/protected/underscore prefix) are RPC-accessible.
2241
2250
  */
2242
2251
  function extractClassMethods(source, className) {
2243
- // Find: class ClassName extends kuratchiDO {
2244
- const classIdx = source.search(new RegExp(`class\\s+${className}\\s+extends\\s+kuratchiDO`));
2252
+ // Find: class ClassName extends DurableObject {
2253
+ const classIdx = source.search(new RegExp(`class\\s+${className}\\s+extends\\s+DurableObject`));
2245
2254
  if (classIdx === -1)
2246
2255
  return [];
2247
2256
  const braceStart = source.indexOf('{', classIdx);
@@ -2335,21 +2344,23 @@ function extractExportedFunctions(source) {
2335
2344
  * Generate a proxy module for a DO handler file.
2336
2345
  *
2337
2346
  * The proxy provides auto-RPC function exports.
2338
- * - Class mode: public class methods become RPC exports.
2339
- * - Function mode: exported functions become RPC exports, excluding lifecycle hooks.
2347
+ * Class mode only: public class methods become RPC exports.
2348
+ * Methods starting with underscore or marked private/protected are excluded.
2340
2349
  */
2341
2350
  function generateHandlerProxy(handler, projectDir) {
2342
2351
  const doDir = path.join(projectDir, '.kuratchi', 'do');
2343
2352
  const origRelPath = path.relative(doDir, handler.absPath).replace(/\\/g, '/').replace(/\.ts$/, '.js');
2344
2353
  const handlerLocal = `__handler_${toSafeIdentifier(handler.fileName)}`;
2345
- const lifecycle = new Set(['onInit', 'onAlarm', 'onMessage']);
2346
- const rpcFunctions = handler.mode === 'function'
2347
- ? handler.exportedFunctions.filter((n) => !lifecycle.has(n))
2348
- : handler.classMethods.filter((m) => m.visibility === 'public').map((m) => m.name);
2354
+ // Lifecycle methods excluded from RPC
2355
+ const lifecycle = new Set(['constructor', 'fetch', 'alarm', 'webSocketMessage', 'webSocketClose', 'webSocketError']);
2356
+ // Only public methods (not starting with _) are RPC-accessible
2357
+ const rpcFunctions = handler.classMethods
2358
+ .filter((m) => m.visibility === 'public' && !m.name.startsWith('_') && !lifecycle.has(m.name))
2359
+ .map((m) => m.name);
2349
2360
  const methods = handler.classMethods.map((m) => ({ ...m }));
2350
2361
  const methodMap = new Map(methods.map((m) => [m.name, m]));
2351
2362
  let changed = true;
2352
- while (changed && handler.mode === 'class') {
2363
+ while (changed) {
2353
2364
  changed = false;
2354
2365
  for (const m of methods) {
2355
2366
  if (m.hasWorkerContextCalls)
@@ -2364,16 +2375,14 @@ function generateHandlerProxy(handler, projectDir) {
2364
2375
  }
2365
2376
  }
2366
2377
  }
2367
- const workerContextMethods = handler.mode === 'class'
2368
- ? methods.filter((m) => m.visibility === 'public' && m.hasWorkerContextCalls).map((m) => m.name)
2369
- : [];
2370
- const asyncMethods = handler.mode === 'class'
2371
- ? methods.filter((m) => m.isAsync).map((m) => m.name)
2372
- : [];
2378
+ const workerContextMethods = methods
2379
+ .filter((m) => m.visibility === 'public' && m.hasWorkerContextCalls)
2380
+ .map((m) => m.name);
2381
+ const asyncMethods = methods.filter((m) => m.isAsync).map((m) => m.name);
2373
2382
  const lines = [
2374
2383
  `// Auto-generated by KuratchiJS compiler �" do not edit.`,
2375
2384
  `import { __getDoStub } from '${RUNTIME_DO_IMPORT}';`,
2376
- ...(handler.mode === 'class' ? [`import ${handlerLocal} from '${origRelPath}';`] : []),
2385
+ `import ${handlerLocal} from '${origRelPath}';`,
2377
2386
  ``,
2378
2387
  `const __FD_TAG = '__kuratchi_form_data__';`,
2379
2388
  `function __isPlainObject(__v) {`,
@@ -2655,20 +2664,16 @@ ${initLines.join('\n')}
2655
2664
  list.push(h);
2656
2665
  handlersByBinding.set(h.binding, list);
2657
2666
  }
2658
- // Import handler files + schema for each DO
2667
+ // Import handler files + schema for each DO (class mode only)
2659
2668
  for (const doEntry of opts.doConfig) {
2660
2669
  const handlers = handlersByBinding.get(doEntry.binding) ?? [];
2661
2670
  const ormDb = opts.ormDatabases.find(d => d.binding === doEntry.binding);
2662
- const fnHandlers = handlers.filter((h) => h.mode === 'function');
2663
- const initHandlers = fnHandlers.filter((h) => h.exportedFunctions.includes('onInit'));
2664
- const alarmHandlers = fnHandlers.filter((h) => h.exportedFunctions.includes('onAlarm'));
2665
- const messageHandlers = fnHandlers.filter((h) => h.exportedFunctions.includes('onMessage'));
2666
2671
  // Import schema (paths are relative to project root; prefix ../ since we're in .kuratchi/)
2667
2672
  if (ormDb) {
2668
2673
  const schemaPath = ormDb.schemaImportPath.replace(/^\.\//, '../');
2669
2674
  doImportLines.push(`import { ${ormDb.schemaExportName} as __doSchema_${doEntry.binding} } from '${schemaPath}';`);
2670
2675
  }
2671
- // Import handler classes
2676
+ // Import handler classes (class mode only - extends DurableObject)
2672
2677
  for (const h of handlers) {
2673
2678
  let handlerImportPath = path
2674
2679
  .relative(path.join(opts.projectDir, '.kuratchi'), h.absPath)
@@ -2677,27 +2682,19 @@ ${initLines.join('\n')}
2677
2682
  if (!handlerImportPath.startsWith('.'))
2678
2683
  handlerImportPath = './' + handlerImportPath;
2679
2684
  const handlerVar = `__handler_${toSafeIdentifier(h.fileName)}`;
2680
- if (h.mode === 'class') {
2681
- doImportLines.push(`import ${handlerVar} from '${handlerImportPath}';`);
2682
- }
2683
- else {
2684
- doImportLines.push(`import * as ${handlerVar} from '${handlerImportPath}';`);
2685
- }
2685
+ doImportLines.push(`import ${handlerVar} from '${handlerImportPath}';`);
2686
2686
  }
2687
- // Generate DO class
2688
- doClassLines.push(`export class ${doEntry.className} extends __DO {`);
2689
- doClassLines.push(` constructor(ctx, env) {`);
2690
- doClassLines.push(` super(ctx, env);`);
2687
+ // Generate DO class that extends the user's class (for ORM integration)
2688
+ // If no ORM, we just re-export the user's class directly
2691
2689
  if (ormDb) {
2690
+ const handler = handlers[0];
2691
+ const handlerVar = handler ? `__handler_${toSafeIdentifier(handler.fileName)}` : '__DO';
2692
+ const baseClass = handler ? handlerVar : '__DO';
2693
+ doClassLines.push(`export class ${doEntry.className} extends ${baseClass} {`);
2694
+ doClassLines.push(` constructor(ctx, env) {`);
2695
+ doClassLines.push(` super(ctx, env);`);
2692
2696
  doClassLines.push(` this.db = __initDO(ctx.storage.sql, __doSchema_${doEntry.binding});`);
2693
- }
2694
- for (const h of initHandlers) {
2695
- const handlerVar = `__handler_${toSafeIdentifier(h.fileName)}`;
2696
- doClassLines.push(` __setDoContext(this);`);
2697
- doClassLines.push(` Promise.resolve(${handlerVar}.onInit.call(this)).catch((err) => console.error('[kuratchi] DO onInit failed:', err?.message || err));`);
2698
- }
2699
- doClassLines.push(` }`);
2700
- if (ormDb) {
2697
+ doClassLines.push(` }`);
2701
2698
  doClassLines.push(` async __kuratchiLogActivity(payload) {`);
2702
2699
  doClassLines.push(` const now = new Date().toISOString();`);
2703
2700
  doClassLines.push(` try {`);
@@ -2733,42 +2730,18 @@ ${initLines.join('\n')}
2733
2730
  doClassLines.push(` if (Number.isFinite(limit) && limit > 0) return rows.slice(0, Math.floor(limit));`);
2734
2731
  doClassLines.push(` return rows;`);
2735
2732
  doClassLines.push(` }`);
2733
+ doClassLines.push(`}`);
2736
2734
  }
2737
- // Function-mode lifecycle dispatchers
2738
- if (alarmHandlers.length > 0) {
2739
- doClassLines.push(` async alarm(...args) {`);
2740
- doClassLines.push(` __setDoContext(this);`);
2741
- for (const h of alarmHandlers) {
2742
- const handlerVar = `__handler_${toSafeIdentifier(h.fileName)}`;
2743
- doClassLines.push(` await ${handlerVar}.onAlarm.call(this, ...args);`);
2744
- }
2745
- doClassLines.push(` }`);
2735
+ else if (handlers.length > 0) {
2736
+ // No ORM - just re-export the user's class directly
2737
+ const handler = handlers[0];
2738
+ const handlerVar = `__handler_${toSafeIdentifier(handler.fileName)}`;
2739
+ doClassLines.push(`export { ${handlerVar} as ${doEntry.className} };`);
2746
2740
  }
2747
- if (messageHandlers.length > 0) {
2748
- doClassLines.push(` webSocketMessage(...args) {`);
2749
- doClassLines.push(` __setDoContext(this);`);
2750
- for (const h of messageHandlers) {
2751
- const handlerVar = `__handler_${toSafeIdentifier(h.fileName)}`;
2752
- doClassLines.push(` ${handlerVar}.onMessage.call(this, ...args);`);
2753
- }
2754
- doClassLines.push(` }`);
2755
- }
2756
- doClassLines.push(`}`);
2757
- // Apply handler methods to prototype (outside class body)
2741
+ // Register class binding for RPC
2758
2742
  for (const h of handlers) {
2759
2743
  const handlerVar = `__handler_${toSafeIdentifier(h.fileName)}`;
2760
- if (h.mode === 'class') {
2761
- doClassLines.push(`for (const __k of Object.getOwnPropertyNames(${handlerVar}.prototype)) { if (__k !== 'constructor') ${doEntry.className}.prototype[__k] = function(...__a){ __setDoContext(this); return ${handlerVar}.prototype[__k].apply(this, __a.map(__decodeDoArg)); }; }`);
2762
- doResolverLines.push(` __registerDoClassBinding(${handlerVar}, '${doEntry.binding}');`);
2763
- }
2764
- else {
2765
- const lifecycle = new Set(['onInit', 'onAlarm', 'onMessage']);
2766
- for (const fn of h.exportedFunctions) {
2767
- if (lifecycle.has(fn))
2768
- continue;
2769
- doClassLines.push(`${doEntry.className}.prototype[${JSON.stringify(fn)}] = function(...__a){ __setDoContext(this); return ${handlerVar}.${fn}.apply(this, __a.map(__decodeDoArg)); };`);
2770
- }
2771
- }
2744
+ doResolverLines.push(` __registerDoClassBinding(${handlerVar}, '${doEntry.binding}');`);
2772
2745
  }
2773
2746
  // Register stub resolver
2774
2747
  if (doEntry.stubId) {
@@ -2787,16 +2760,42 @@ ${initLines.join('\n')}
2787
2760
  }
2788
2761
  }
2789
2762
  doImports = doImportLines.join('\n');
2790
- doClassCode = `\n// �"��"� Durable Object Classes (generated) �"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"�\n\n` + doClassLines.join('\n') + '\n';
2763
+ doClassCode = `\n// ── Durable Object Classes (generated) ─────────────────────────\n\n` + doClassLines.join('\n') + '\n';
2791
2764
  doResolverInit = `\nfunction __initDoResolvers() {\n${doResolverLines.join('\n')}\n}\n`;
2792
2765
  }
2766
+ // Generate workflow status RPC handlers for auto-discovered workflows
2767
+ // Naming: migration.workflow.ts -> migrationWorkflowStatus(instanceId)
2768
+ let workflowStatusRpc = '';
2769
+ if (opts.workflowConfig.length > 0) {
2770
+ const rpcLines = [];
2771
+ rpcLines.push(`\n// ── Workflow Status RPCs (auto-generated) ─────────────────────`);
2772
+ rpcLines.push(`const __workflowStatusRpc = {`);
2773
+ for (const wf of opts.workflowConfig) {
2774
+ // file: src/server/migration.workflow.ts -> camelCase RPC name: migrationWorkflowStatus
2775
+ const baseName = wf.file.split('/').pop()?.replace(/\.workflow\.ts$/, '') || '';
2776
+ const camelName = baseName.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
2777
+ const rpcName = `${camelName}WorkflowStatus`;
2778
+ rpcLines.push(` '${rpcName}': async (instanceId) => {`);
2779
+ rpcLines.push(` if (!instanceId) return { status: 'unknown', error: { name: 'Error', message: 'Missing instanceId' } };`);
2780
+ rpcLines.push(` try {`);
2781
+ rpcLines.push(` const instance = await __env.${wf.binding}.get(instanceId);`);
2782
+ rpcLines.push(` return await instance.status();`);
2783
+ rpcLines.push(` } catch (err) {`);
2784
+ rpcLines.push(` return { status: 'errored', error: { name: err?.name || 'Error', message: err?.message || 'Unknown error' } };`);
2785
+ rpcLines.push(` }`);
2786
+ rpcLines.push(` },`);
2787
+ }
2788
+ rpcLines.push(`};`);
2789
+ workflowStatusRpc = rpcLines.join('\n');
2790
+ }
2793
2791
  return `// Generated by KuratchiJS compiler �" do not edit.
2794
2792
  ${opts.isDev ? '\nglobalThis.__kuratchi_DEV__ = true;\n' : ''}
2795
2793
  ${workerImport}
2796
2794
  ${contextImport}
2797
2795
  ${runtimeImport ? runtimeImport + '\n' : ''}${migrationImports ? migrationImports + '\n' : ''}${authPluginImports ? authPluginImports + '\n' : ''}${doImports ? doImports + '\n' : ''}${opts.serverImports.join('\n')}
2796
+ ${workflowStatusRpc}
2798
2797
 
2799
- // �"��"� Assets �"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"��"�
2798
+ // ── Assets ─────────────────────────────────────────────────────
2800
2799
 
2801
2800
  const __assets = {
2802
2801
  ${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')}
@@ -2925,8 +2924,49 @@ function __isSameOrigin(request, url) {
2925
2924
  try { return new URL(origin).origin === url.origin; } catch { return false; }
2926
2925
  }
2927
2926
 
2928
- ${opts.isLayoutAsync ? 'async ' : ''}function __render(route, data) {
2927
+ // Extract fragment content by ID from rendered HTML
2928
+ function __extractFragment(html, fragmentId) {
2929
+ // Find the element with data-poll-id="fragmentId" and extract its innerHTML
2930
+ const escaped = fragmentId.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&');
2931
+ const openTagRegex = new RegExp('<([a-z][a-z0-9]*)\\\\s[^>]*data-poll-id="' + escaped + '"[^>]*>', 'i');
2932
+ const match = html.match(openTagRegex);
2933
+ if (!match) return null;
2934
+ const tagName = match[1];
2935
+ const startIdx = match.index + match[0].length;
2936
+ // Find matching closing tag (handle nesting)
2937
+ let depth = 1;
2938
+ let i = startIdx;
2939
+ const closeTag = '</' + tagName + '>';
2940
+ const openTag = '<' + tagName;
2941
+ while (i < html.length && depth > 0) {
2942
+ const nextClose = html.indexOf(closeTag, i);
2943
+ const nextOpen = html.indexOf(openTag, i);
2944
+ if (nextClose === -1) break;
2945
+ if (nextOpen !== -1 && nextOpen < nextClose) {
2946
+ depth++;
2947
+ i = nextOpen + openTag.length;
2948
+ } else {
2949
+ depth--;
2950
+ if (depth === 0) return html.slice(startIdx, nextClose);
2951
+ i = nextClose + closeTag.length;
2952
+ }
2953
+ }
2954
+ return null;
2955
+ }
2956
+
2957
+ ${opts.isLayoutAsync ? 'async ' : ''}function __render(route, data, fragmentId) {
2929
2958
  let html = route.render(data);
2959
+
2960
+ // Fragment request: return only the fragment's innerHTML
2961
+ if (fragmentId) {
2962
+ const fragment = __extractFragment(html, fragmentId);
2963
+ if (fragment !== null) {
2964
+ return new Response(fragment, { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });
2965
+ }
2966
+ return new Response('Fragment not found', { status: 404 });
2967
+ }
2968
+
2969
+ // Full page render
2930
2970
  const headMatch = html.match(/<head>([\\s\\S]*?)<\\/head>/);
2931
2971
  if (headMatch) {
2932
2972
  html = html.replace(headMatch[0], '');
@@ -3012,6 +3052,7 @@ ${migrationInit ? ' await __runMigrations();\n' : ''}${authInit ? ' __init
3012
3052
  const __coreFetch = async () => {
3013
3053
  const request = __runtimeCtx.request;
3014
3054
  const url = __runtimeCtx.url;
3055
+ const __fragmentId = request.headers.get('x-kuratchi-fragment');
3015
3056
  ${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' : ''}
3016
3057
 
3017
3058
  // Serve static assets from src/assets/
@@ -3070,7 +3111,9 @@ ${ac?.hasRateLimit ? '\n // Rate limiting - check before route handlers\n
3070
3111
 
3071
3112
  // RPC call: GET ?_rpc=fnName&_args=[...] -> JSON response
3072
3113
  const __rpcName = url.searchParams.get('_rpc');
3073
- if (request.method === 'GET' && __rpcName && route.rpc && Object.hasOwn(route.rpc, __rpcName)) {
3114
+ const __hasRouteRpc = __rpcName && route.rpc && Object.hasOwn(route.rpc, __rpcName);
3115
+ const __hasWorkflowRpc = __rpcName && typeof __workflowStatusRpc !== 'undefined' && Object.hasOwn(__workflowStatusRpc, __rpcName);
3116
+ if (request.method === 'GET' && __rpcName && (__hasRouteRpc || __hasWorkflowRpc)) {
3074
3117
  if (request.headers.get('x-kuratchi-rpc') !== '1') {
3075
3118
  return __secHeaders(new Response(JSON.stringify({ ok: false, error: 'Forbidden' }), {
3076
3119
  status: 403, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }
@@ -3083,7 +3126,8 @@ ${ac?.hasRateLimit ? '\n // Rate limiting - check before route handlers\n
3083
3126
  const __parsed = JSON.parse(__rpcArgsStr);
3084
3127
  __rpcArgs = Array.isArray(__parsed) ? __parsed : [];
3085
3128
  }
3086
- const __rpcResult = await route.rpc[__rpcName](...__rpcArgs);
3129
+ const __rpcFn = __hasRouteRpc ? route.rpc[__rpcName] : __workflowStatusRpc[__rpcName];
3130
+ const __rpcResult = await __rpcFn(...__rpcArgs);
3087
3131
  return __secHeaders(new Response(JSON.stringify({ ok: true, data: __rpcResult }), {
3088
3132
  headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }
3089
3133
  }));
@@ -3143,7 +3187,7 @@ ${ac?.hasRateLimit ? '\n // Rate limiting - check before route handlers\n
3143
3187
  Object.keys(__allActions).forEach(function(k) { if (!(k in data)) data[k] = { error: undefined, loading: false, success: false }; });
3144
3188
  const __errMsg = (err && err.isActionError) ? err.message : (typeof __kuratchi_DEV__ !== 'undefined' && err && err.message) ? err.message : 'Action failed';
3145
3189
  data[actionName] = { error: __errMsg, loading: false, success: false };
3146
- return ${opts.isLayoutAsync ? 'await ' : ''}__render(route, data);
3190
+ return ${opts.isLayoutAsync ? 'await ' : ''}__render(route, data, __fragmentId);
3147
3191
  }
3148
3192
  // Fetch-based actions return lightweight JSON (no page re-render)
3149
3193
  if (isFetchAction) {
@@ -3167,7 +3211,7 @@ ${ac?.hasRateLimit ? '\n // Rate limiting - check before route handlers\n
3167
3211
  data.breadcrumbs = __getLocals().__breadcrumbs ?? [];
3168
3212
  const __allActionsGet = Object.assign({}, route.actions, __layoutActions || {});
3169
3213
  Object.keys(__allActionsGet).forEach(function(k) { if (!(k in data)) data[k] = { error: undefined, loading: false, success: false }; });
3170
- return ${opts.isLayoutAsync ? 'await ' : ''}__render(route, data);
3214
+ return ${opts.isLayoutAsync ? 'await ' : ''}__render(route, data, __fragmentId);
3171
3215
  } catch (err) {
3172
3216
  if (err && err.isRedirectError) {
3173
3217
  const __redirectTo = err.location || url.pathname;
@@ -764,7 +764,7 @@ function compileHtmlLine(line, actionNames, rpcNameMap) {
764
764
  continue;
765
765
  }
766
766
  else if (attrName === 'data-poll') {
767
- // data-poll={fn(args)} → data-poll="fnName" data-poll-args="[serialized]"
767
+ // data-poll={fn(args)} data-poll="fnName" data-poll-args="[serialized]" data-poll-id="stable-id"
768
768
  const pollCallMatch = inner.match(/^(\w+)\((.*)\)$/);
769
769
  if (pollCallMatch) {
770
770
  const fnName = pollCallMatch[1];
@@ -772,10 +772,16 @@ function compileHtmlLine(line, actionNames, rpcNameMap) {
772
772
  const argsExpr = pollCallMatch[2].trim();
773
773
  // Remove the trailing "data-poll=" we already appended
774
774
  result = result.replace(/\s*data-poll=$/, '');
775
- // Emit data-poll and data-poll-args attributes
775
+ // Emit data-poll, data-poll-args, and stable data-poll-id (based on fn + args expression)
776
776
  result += ` data-poll="${rpcName}"`;
777
777
  if (argsExpr) {
778
778
  result += ` data-poll-args="\${__esc(JSON.stringify([${argsExpr}]))}"`;
779
+ // Stable ID based on args so same data produces same ID across renders
780
+ result += ` data-poll-id="\${__esc('__poll_' + String(${argsExpr}).replace(/[^a-zA-Z0-9]/g, '_'))}"`;
781
+ }
782
+ else {
783
+ // No args - use function name as ID
784
+ result += ` data-poll-id="__poll_${rpcName}"`;
779
785
  }
780
786
  }
781
787
  hasExpr = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuratchi/js",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "description": "A thin, Cloudflare Workers-native web framework with Svelte-inspired syntax",
5
5
  "license": "MIT",
6
6
  "type": "module",