@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 +125 -60
- package/dist/compiler/index.js +195 -151
- package/dist/compiler/template.js +8 -2
- package/package.json +1 -1
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
|
-
|
|
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-
|
|
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
|
-
###
|
|
417
|
+
### Writing a Durable Object
|
|
401
418
|
|
|
402
|
-
|
|
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/
|
|
407
|
-
import {
|
|
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
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
}
|
|
425
|
+
export default class UserDO extends DurableObject {
|
|
426
|
+
async getName() {
|
|
427
|
+
return await this.ctx.storage.get('name');
|
|
428
|
+
}
|
|
423
429
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
430
|
+
async setName(name: string) {
|
|
431
|
+
this._validate(name);
|
|
432
|
+
await this.ctx.storage.put('name', name);
|
|
433
|
+
}
|
|
427
434
|
|
|
428
|
-
|
|
429
|
-
|
|
435
|
+
// NOT RPC-accessible (underscore prefix)
|
|
436
|
+
_validate(name: string) {
|
|
437
|
+
if (!name) throw new Error('Name required');
|
|
438
|
+
}
|
|
430
439
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
440
|
+
// NOT RPC-accessible (lifecycle method)
|
|
441
|
+
async alarm() {
|
|
442
|
+
// Handle alarm
|
|
443
|
+
}
|
|
434
444
|
}
|
|
435
445
|
```
|
|
436
446
|
|
|
437
|
-
|
|
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
|
-
|
|
440
|
-
- `export async function onAlarm(...args)`
|
|
441
|
-
- `export function onMessage(...args)`
|
|
453
|
+
### Using from routes
|
|
442
454
|
|
|
443
|
-
|
|
455
|
+
Import from `$do/<filename>` (without the `.do` suffix):
|
|
444
456
|
|
|
445
|
-
|
|
457
|
+
```html
|
|
458
|
+
<script server>
|
|
459
|
+
import { getName, setName } from '$do/user';
|
|
446
460
|
|
|
447
|
-
|
|
461
|
+
const name = await getName();
|
|
462
|
+
</script>
|
|
448
463
|
|
|
449
|
-
|
|
450
|
-
|
|
464
|
+
<h1>Hello, {name}</h1>
|
|
465
|
+
```
|
|
451
466
|
|
|
452
|
-
|
|
453
|
-
static binding = 'NOTES_DO';
|
|
467
|
+
The framework handles RPC wiring automatically.
|
|
454
468
|
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
485
|
+
The framework auto-syncs discovered DOs to `wrangler.jsonc`.
|
|
462
486
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/dist/compiler/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
480
|
-
|
|
481
|
-
var
|
|
482
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
.then(function(
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
},
|
|
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
|
-
//
|
|
735
|
-
const
|
|
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
|
-
*
|
|
2157
|
-
*
|
|
2158
|
-
*
|
|
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
|
|
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
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
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
|
-
|
|
2187
|
-
const hasClass = /extends\s+
|
|
2188
|
-
if (!hasClass
|
|
2200
|
+
// Must extend DurableObject
|
|
2201
|
+
const hasClass = /extends\s+DurableObject\b/.test(source);
|
|
2202
|
+
if (!hasClass)
|
|
2189
2203
|
continue;
|
|
2190
|
-
// Extract class name
|
|
2191
|
-
const classMatch = source.match(/export\s+default\s+class\s+(\w+)\s+extends\s+
|
|
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 (
|
|
2207
|
+
if (!className)
|
|
2194
2208
|
continue;
|
|
2195
|
-
//
|
|
2196
|
-
//
|
|
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
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
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:
|
|
2232
|
-
className
|
|
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
|
|
2244
|
-
const classIdx = source.search(new RegExp(`class\\s+${className}\\s+extends\\s+
|
|
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
|
-
*
|
|
2339
|
-
*
|
|
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
|
-
|
|
2346
|
-
const
|
|
2347
|
-
|
|
2348
|
-
|
|
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
|
|
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 =
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
const asyncMethods =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)}
|
|
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
|
|
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;
|