@kuratchi/js 0.0.18 → 0.0.20

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.
Files changed (40) hide show
  1. package/README.md +172 -9
  2. package/dist/compiler/client-module-pipeline.d.ts +8 -0
  3. package/dist/compiler/client-module-pipeline.js +181 -30
  4. package/dist/compiler/compiler-shared.d.ts +23 -0
  5. package/dist/compiler/config-reading.js +27 -1
  6. package/dist/compiler/convention-discovery.d.ts +2 -0
  7. package/dist/compiler/convention-discovery.js +16 -0
  8. package/dist/compiler/durable-object-pipeline.d.ts +1 -0
  9. package/dist/compiler/durable-object-pipeline.js +459 -119
  10. package/dist/compiler/import-linking.js +1 -1
  11. package/dist/compiler/index.js +41 -2
  12. package/dist/compiler/page-route-pipeline.js +31 -2
  13. package/dist/compiler/parser.d.ts +1 -0
  14. package/dist/compiler/parser.js +47 -4
  15. package/dist/compiler/root-layout-pipeline.js +26 -1
  16. package/dist/compiler/route-discovery.js +5 -5
  17. package/dist/compiler/route-pipeline.d.ts +2 -0
  18. package/dist/compiler/route-pipeline.js +28 -4
  19. package/dist/compiler/routes-module-feature-blocks.js +149 -17
  20. package/dist/compiler/routes-module-types.d.ts +1 -0
  21. package/dist/compiler/template.d.ts +4 -0
  22. package/dist/compiler/template.js +50 -18
  23. package/dist/compiler/worker-output-pipeline.js +2 -0
  24. package/dist/compiler/wrangler-sync.d.ts +3 -0
  25. package/dist/compiler/wrangler-sync.js +25 -11
  26. package/dist/create.js +6 -6
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +1 -0
  29. package/dist/runtime/context.d.ts +6 -0
  30. package/dist/runtime/context.js +22 -1
  31. package/dist/runtime/generated-worker.d.ts +1 -0
  32. package/dist/runtime/generated-worker.js +11 -7
  33. package/dist/runtime/index.d.ts +2 -0
  34. package/dist/runtime/index.js +1 -0
  35. package/dist/runtime/schema.d.ts +49 -0
  36. package/dist/runtime/schema.js +148 -0
  37. package/dist/runtime/types.d.ts +2 -0
  38. package/dist/runtime/validation.d.ts +26 -0
  39. package/dist/runtime/validation.js +147 -0
  40. package/package.json +5 -1
package/README.md CHANGED
@@ -42,9 +42,9 @@ For the framework's internal compiler/runtime orchestration and tracked implemen
42
42
  Place `.html` files inside `src/routes/`. The file path becomes the URL pattern.
43
43
 
44
44
  ```
45
- src/routes/page.html → /
46
- src/routes/items/page.html → /items
47
- src/routes/blog/[slug]/page.html → /blog/:slug
45
+ src/routes/index.html → /
46
+ src/routes/items/index.html → /items
47
+ src/routes/blog/[slug]/index.html → /blog/:slug
48
48
  src/routes/layout.html → shared layout wrapping all routes
49
49
  ```
50
50
 
@@ -183,6 +183,60 @@ Notes:
183
183
  - `$: name = expr` works; when replacing proxy-backed values, the compiler preserves reactivity under the hood.
184
184
  - You should not need `if (browser)` style guards in normal Kuratchi route code. If browser checks become necessary outside `$:`, the boundary is likely in the wrong place.
185
185
 
186
+ ### `$client` server calls
187
+
188
+ Use `$client/*` for browser orchestration, then import server functions from `$server/*` inside that client module. Kuratchi generates a route-scoped browser RPC proxy automatically.
189
+
190
+ ```ts
191
+ // src/client/migration.ts
192
+ import { testMigrationConnection } from '$server/incus';
193
+
194
+ export async function testConnection(sourceIp: string) {
195
+ const result = await testMigrationConnection(sourceIp);
196
+ console.log(result.success, result.message);
197
+ }
198
+ ```
199
+
200
+ ```html
201
+ <script>
202
+ import { testConnection } from '$client/migration';
203
+ </script>
204
+
205
+ <button onclick={testConnection(sourceIp)}>Test Connection</button>
206
+ ```
207
+
208
+ Behavior:
209
+ - Kuratchi only ships the `$client` module to the browser.
210
+ - Any `$server` imports used inside that `$client` graph become generated fetch proxies for the current route.
211
+ - Your client handler can `await` them directly from `onclick={fn(args)}` or from browser-side `$:` blocks.
212
+
213
+ Failure and edge behavior:
214
+ - Named and default `$server` imports are supported inside `$client` / `$shared` browser graphs.
215
+ - Namespace imports like `import * as api from '$server/foo'` are currently rejected in browser code.
216
+ - Remote call failures reject with the server error message when available, otherwise `HTTP <status>`.
217
+
218
+ ### Awaited remote reads
219
+
220
+ For renderable remote reads, use direct `await fn(args)` markup. Kuratchi lowers it to a route query, renders it on the server, and refreshes it after successful `$client` remote calls.
221
+
222
+ ```html
223
+ <script>
224
+ import { getMigrationConnectionStatus } from '$server/incus';
225
+ </script>
226
+
227
+ <p>{await getMigrationConnectionStatus(sourceIp)}</p>
228
+ ```
229
+
230
+ Behavior:
231
+ - The read runs during the initial server render.
232
+ - Kuratchi emits refresh metadata so the same block can be re-fetched without a full page reload.
233
+ - Successful `$client` remote calls automatically invalidate awaited reads on the current page.
234
+
235
+ Failure and edge behavior:
236
+ - The supported syntax is direct markup form: `{await fn(args)}`.
237
+ - Awaited reads are intended for values that render cleanly to text/HTML output.
238
+ - Complex promise expressions or chained property access should be wrapped in a dedicated server helper that returns the render-ready value.
239
+
186
240
  ## Form actions
187
241
 
188
242
  Export server functions from a route's `<script>` block and reference them with `action={fn}`. The compiler automatically registers them as dispatchable actions.
@@ -277,7 +331,9 @@ Throw `PageError` from a route's load scope to return the correct HTTP error pag
277
331
  ```ts
278
332
  import { PageError } from '@kuratchi/js';
279
333
 
280
- // In src/routes/posts/[id]/page.html <script> block:
334
+ // In src/routes/posts/[id]/index.html <script> block:
335
+ import { params } from '@kuratchi/js/request';
336
+
281
337
  const post = await db.posts.findOne({ id: params.id });
282
338
  if (!post) throw new PageError(404);
283
339
  if (!post.isPublished && !currentUser?.isAdmin) throw new PageError(403);
@@ -341,14 +397,33 @@ After a `data-action` call succeeds, elements with `data-refresh` re-fetch their
341
397
  </section>
342
398
  ```
343
399
 
344
- ### `data-get` — client-side navigation
400
+ ### `data-get` — query blocks and refreshable reads
345
401
 
346
- Navigate to a URL on click (respects `http:`/`https:` only):
402
+ Use `data-get={fn(args)}` with `data-as` to bind a server read into a fragment that Kuratchi can refresh:
347
403
 
348
404
  ```html
349
- <div data-get="/items/{item.id}">Click to navigate</div>
405
+ <section data-get={getItems(projectId)} data-as="items">
406
+ if (items.loading) {
407
+ <p>Loading…</p>
408
+ } else if (items.error) {
409
+ <p>{items.error}</p>
410
+ } else {
411
+ for (const item of (items.data ?? [])) {
412
+ <article>{item.title}</article>
413
+ }
414
+ }
415
+ </section>
350
416
  ```
351
417
 
418
+ Behavior:
419
+ - The server read runs on the initial render.
420
+ - Kuratchi stores fragment metadata so the block can be refreshed by `data-refresh`, polling, or invalidation.
421
+ - Query state follows `{ loading, error, success, empty, data, state }`.
422
+
423
+ Failure and edge behavior:
424
+ - `data-as` is required for query-state blocks.
425
+ - `data-get="/path"` still works as click-to-navigate when used as a plain string URL on an element without query state metadata.
426
+
352
427
  ### `data-poll` — polling
353
428
 
354
429
  Poll and update an element's content at a human-readable interval with automatic exponential backoff:
@@ -408,6 +483,91 @@ For Durable Objects, RPC is file-driven and automatic.
408
483
  <button type="submit">Create</button>
409
484
  </form>
410
485
  ```
486
+
487
+ ### RPC Validation Without Dependencies
488
+
489
+ Kuratchi ships a small built-in schema API for route RPCs and Durable Object RPC methods, so you do not need `zod`, `valibot`, or any other runtime dependency just to validate client-callable input.
490
+
491
+ Declare schemas in a companion `schemas` object. Keys must match the public RPC function or method names:
492
+
493
+ ```ts
494
+ import { schema, type InferSchema } from '@kuratchi/js';
495
+
496
+ export const schemas = {
497
+ createSite: schema({
498
+ name: schema.string().min(1),
499
+ slug: schema.string().min(1),
500
+ publish: schema.boolean().optional(false),
501
+ }),
502
+ };
503
+
504
+ export async function createSite(data: InferSchema<typeof schemas.createSite>) {
505
+ return { id: `${data.slug}-1`, publish: data.publish };
506
+ }
507
+ ```
508
+
509
+ This works for normal exported route RPC functions without changing the function declaration style. The schema lives alongside the function instead of wrapping it.
510
+
511
+ Durable Object classes use the same convention via `static schemas`:
512
+
513
+ ```ts
514
+ import { DurableObject } from 'cloudflare:workers';
515
+ import { schema, type InferSchema } from '@kuratchi/js';
516
+
517
+ export default class SitesDO extends DurableObject {
518
+ static schemas = {
519
+ saveDraft: schema({
520
+ title: schema.string().min(1),
521
+ content: schema.string().min(1),
522
+ }),
523
+ };
524
+
525
+ async saveDraft(data: InferSchema<(typeof SitesDO.schemas).saveDraft>) {
526
+ return { ok: true, slug: data.title.toLowerCase().replace(/ /g, '-') };
527
+ },
528
+ }
529
+ ```
530
+
531
+ If the payload does not match the schema, Kuratchi returns `400` with a validation error instead of executing the RPC. Schema-backed RPCs accept a single object argument.
532
+
533
+ Rules:
534
+ - Route RPC modules use `export const schemas = { ... }`.
535
+ - Durable Object classes use `static schemas = { ... }`.
536
+ - Schema keys must match public function or method names exactly.
537
+ - Schema-backed RPC entrypoints take one object argument.
538
+ - Today, the typed handler pattern is `InferSchema<typeof schemas.name>` or `InferSchema<(typeof MyDO.schemas).methodName>`.
539
+
540
+ Available schema builders:
541
+ - `schema({ ... })`
542
+ - `schema.string()`
543
+ - `schema.number()`
544
+ - `schema.boolean()`
545
+ - `schema.file()`
546
+ - `.optional(defaultValue)`
547
+ - `.list()`
548
+ - `.min(value)`
549
+
550
+ Example with nested objects, arrays, and defaults:
551
+
552
+ ```ts
553
+ import { schema, type InferSchema } from '@kuratchi/js';
554
+
555
+ export const schemas = {
556
+ createProfile: schema({
557
+ name: schema.string().min(1),
558
+ info: schema({
559
+ height: schema.number(),
560
+ likesDogs: schema.boolean().optional(false),
561
+ }),
562
+ attributes: schema.string().list(),
563
+ }),
564
+ };
565
+
566
+ export async function createProfile(data: InferSchema<typeof schemas.createProfile>) {
567
+ return { ok: true, profile: data };
568
+ }
569
+ ```
570
+
411
571
  ## Durable Objects
412
572
 
413
573
  Durable Object behavior is enabled by filename suffix.
@@ -676,18 +836,21 @@ import {
676
836
  For a batteries-included request layer, import pre-parsed request state from `@kuratchi/js/request`:
677
837
 
678
838
  ```ts
679
- import { url, pathname, searchParams, slug } from '@kuratchi/js/request';
839
+ import { url, pathname, searchParams, params, slug } from '@kuratchi/js/request';
680
840
 
681
841
  const page = pathname;
682
842
  const tab = searchParams.get('tab');
843
+ const postId = params.id;
683
844
  const postSlug = slug;
684
845
  ```
685
846
 
686
847
  - `url` is the parsed `URL` for the current request.
687
848
  - `pathname` is the full path, like `/blog/hello-world`.
688
849
  - `searchParams` is `url.searchParams` for the current request.
850
+ - `params` is the matched route params object, like `{ slug: 'hello-world' }`.
689
851
  - `slug` is `params.slug` when the matched route defines a `slug` param.
690
- - `headers`, `method`, and `params` are also exported from `@kuratchi/js/request`.
852
+ - `headers` and `method` are also exported from `@kuratchi/js/request`.
853
+ - `params` is not ambient; import it from `@kuratchi/js/request` or use `getParams()` / `getParam()` from `@kuratchi/js`.
691
854
  - Use `getRequest()` when you want the raw native `Request` object.
692
855
 
693
856
  ## Runtime Hook
@@ -5,6 +5,13 @@ export interface ClientEventRegistration {
5
5
  handlerId: string;
6
6
  argsExpr: string | null;
7
7
  }
8
+ export interface ClientServerProxyBinding {
9
+ sourceKey: string;
10
+ moduleSpecifier: string;
11
+ importerDir: string;
12
+ importedName: string;
13
+ rpcId: string;
14
+ }
8
15
  export interface ClientModuleCompiler {
9
16
  createRegistry(scopeId: string, importEntries: RouteImportEntry[]): ClientRouteRegistry;
10
17
  createRouteRegistry(routeIndex: number, importEntries: RouteImportEntry[]): ClientRouteRegistry;
@@ -14,6 +21,7 @@ export interface ClientRouteRegistry {
14
21
  hasBindings(): boolean;
15
22
  hasBindingReference(expression: string): boolean;
16
23
  registerEventHandler(eventName: string, expression: string): ClientEventRegistration | null;
24
+ getServerProxyBindings(): ClientServerProxyBinding[];
17
25
  buildEntryAsset(): {
18
26
  assetName: string;
19
27
  asset: CompiledAsset;
@@ -1,6 +1,7 @@
1
1
  import * as crypto from 'node:crypto';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
+ import ts from 'typescript';
4
5
  import { collectReferencedIdentifiers, parseImportStatement } from './import-linking.js';
5
6
  function resolveExistingModuleFile(absBase) {
6
7
  const candidates = [
@@ -20,6 +21,19 @@ function resolveExistingModuleFile(absBase) {
20
21
  }
21
22
  return null;
22
23
  }
24
+ function normalizePath(value) {
25
+ return value.replace(/\\/g, '/');
26
+ }
27
+ function isWithinDir(target, dir) {
28
+ const relative = path.relative(dir, target);
29
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
30
+ }
31
+ function getTopLevelImportLines(source) {
32
+ const sourceFile = ts.createSourceFile('kuratchi-client-module.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
33
+ return sourceFile.statements
34
+ .filter(ts.isImportDeclaration)
35
+ .map((statement) => statement.getText(sourceFile).trim());
36
+ }
23
37
  function buildAsset(name, content) {
24
38
  return {
25
39
  name,
@@ -44,19 +58,24 @@ function rewriteImportSpecifiers(source, rewriteSpecifier) {
44
58
  return rewritten;
45
59
  }
46
60
  function resolveClientImportTarget(srcDir, importerAbs, spec) {
61
+ const serverDir = path.join(srcDir, 'server');
47
62
  if (spec.startsWith('$')) {
48
63
  const slashIdx = spec.indexOf('/');
49
64
  const folder = slashIdx === -1 ? spec.slice(1) : spec.slice(1, slashIdx);
50
65
  const rest = slashIdx === -1 ? '' : spec.slice(slashIdx + 1);
51
- if (folder !== 'client' && folder !== 'shared') {
52
- throw new Error(`[kuratchi compiler] Unsupported browser import realm "${spec}". Only $client/* and $shared/* may be loaded in the browser.`);
66
+ if (folder !== 'client' && folder !== 'shared' && folder !== 'server') {
67
+ throw new Error(`[kuratchi compiler] Unsupported browser import realm "${spec}". Only $client/*, $shared/*, and $server/* may be referenced from browser code.`);
53
68
  }
54
69
  const abs = path.join(srcDir, folder, rest);
55
70
  const resolved = resolveExistingModuleFile(abs);
56
71
  if (!resolved) {
57
72
  throw new Error(`[kuratchi compiler] Browser import not found: ${spec}`);
58
73
  }
59
- return resolved;
74
+ return {
75
+ kind: folder === 'server' ? 'server' : 'browser',
76
+ resolved,
77
+ moduleSpecifier: spec,
78
+ };
60
79
  }
61
80
  if (spec.startsWith('.')) {
62
81
  const abs = path.resolve(path.dirname(importerAbs), spec);
@@ -64,15 +83,28 @@ function resolveClientImportTarget(srcDir, importerAbs, spec) {
64
83
  if (!resolved) {
65
84
  throw new Error(`[kuratchi compiler] Browser import not found: ${spec}`);
66
85
  }
67
- return resolved;
86
+ return {
87
+ kind: isWithinDir(resolved, serverDir) ? 'server' : 'browser',
88
+ resolved,
89
+ moduleSpecifier: spec,
90
+ };
68
91
  }
69
- throw new Error(`[kuratchi compiler] Browser modules currently only support project-local imports ($client, $shared, or relative). Unsupported import: ${spec}`);
92
+ throw new Error(`[kuratchi compiler] Browser modules currently only support project-local imports ($client, $shared, $server, or relative). Unsupported import: ${spec}`);
93
+ }
94
+ function buildServerProxyRpcId(sourceKey, importedName) {
95
+ const hash = crypto.createHash('sha1').update(`${sourceKey}:${importedName}`).digest('hex').slice(0, 12);
96
+ return `rpc_remote_${hash}`;
70
97
  }
71
98
  class CompilerBackedClientRouteRegistry {
72
99
  compiler;
73
100
  bindingMap = new Map();
74
101
  clientOnlyBindings = new Set();
75
102
  handlerByKey = new Map();
103
+ routeModuleAssets = new Map();
104
+ serverProxyBindings = new Map();
105
+ serverProxyAssets = new Map();
106
+ scannedBrowserModules = new Set();
107
+ prepared = false;
76
108
  routeId;
77
109
  constructor(compiler, scopeId, importEntries) {
78
110
  this.compiler = compiler;
@@ -149,9 +181,14 @@ class CompilerBackedClientRouteRegistry {
149
181
  argsExpr: parsed.argsExpr,
150
182
  };
151
183
  }
184
+ getServerProxyBindings() {
185
+ this.prepareClientGraph();
186
+ return Array.from(this.serverProxyBindings.values());
187
+ }
152
188
  buildEntryAsset() {
153
189
  if (this.handlerByKey.size === 0)
154
190
  return null;
191
+ this.prepareClientGraph();
155
192
  const usedImportLines = new Map();
156
193
  for (const record of this.handlerByKey.values()) {
157
194
  const binding = this.bindingMap.get(record.rootBinding);
@@ -170,8 +207,11 @@ class CompilerBackedClientRouteRegistry {
170
207
  const parsed = parseImportStatement(entry.line);
171
208
  if (!parsed.moduleSpecifier)
172
209
  continue;
173
- const targetAbs = resolveClientImportTarget(this.compiler.srcDir, path.join(entry.importerDir, '__route__.ts'), parsed.moduleSpecifier);
174
- const targetAssetName = this.compiler.transformClientModule(targetAbs);
210
+ const target = resolveClientImportTarget(this.compiler.srcDir, path.join(entry.importerDir, '__route__.ts'), parsed.moduleSpecifier);
211
+ if (target.kind !== 'browser') {
212
+ throw new Error(`[kuratchi compiler] Top-level route browser imports cannot reference server modules directly: ${parsed.moduleSpecifier}`);
213
+ }
214
+ const targetAssetName = this.transformRouteClientModule(target.resolved);
175
215
  const relSpecifier = toRelativeSpecifier(assetName, targetAssetName);
176
216
  importLines.push(entry.line.replace(parsed.moduleSpecifier, relSpecifier));
177
217
  }
@@ -189,6 +229,140 @@ class CompilerBackedClientRouteRegistry {
189
229
  this.compiler.registerAsset(asset);
190
230
  return { assetName, asset };
191
231
  }
232
+ prepareClientGraph() {
233
+ if (this.prepared)
234
+ return;
235
+ this.prepared = true;
236
+ const usedImportLines = new Map();
237
+ for (const record of this.handlerByKey.values()) {
238
+ const binding = this.bindingMap.get(record.rootBinding);
239
+ if (!binding)
240
+ continue;
241
+ if (!usedImportLines.has(binding.importLine)) {
242
+ usedImportLines.set(binding.importLine, {
243
+ line: binding.importLine,
244
+ importerDir: binding.importerDir,
245
+ });
246
+ }
247
+ }
248
+ for (const entry of usedImportLines.values()) {
249
+ const parsed = parseImportStatement(entry.line);
250
+ if (!parsed.moduleSpecifier)
251
+ continue;
252
+ const target = resolveClientImportTarget(this.compiler.srcDir, path.join(entry.importerDir, '__route__.ts'), parsed.moduleSpecifier);
253
+ if (target.kind === 'browser') {
254
+ this.scanBrowserModule(target.resolved);
255
+ }
256
+ }
257
+ for (const binding of this.serverProxyBindings.values()) {
258
+ this.ensureServerProxyAsset(binding.sourceKey);
259
+ }
260
+ }
261
+ scanBrowserModule(entryAbsPath) {
262
+ const resolved = resolveExistingModuleFile(entryAbsPath) ?? entryAbsPath;
263
+ const normalized = normalizePath(resolved);
264
+ if (this.scannedBrowserModules.has(normalized))
265
+ return;
266
+ this.scannedBrowserModules.add(normalized);
267
+ if (!fs.existsSync(resolved)) {
268
+ throw new Error(`[kuratchi compiler] Browser module not found: ${resolved}`);
269
+ }
270
+ const source = fs.readFileSync(resolved, 'utf-8');
271
+ for (const importLine of getTopLevelImportLines(source)) {
272
+ const parsed = parseImportStatement(importLine);
273
+ if (!parsed.moduleSpecifier)
274
+ continue;
275
+ const target = resolveClientImportTarget(this.compiler.srcDir, resolved, parsed.moduleSpecifier);
276
+ if (target.kind === 'browser') {
277
+ this.scanBrowserModule(target.resolved);
278
+ continue;
279
+ }
280
+ if (parsed.namespaceImport) {
281
+ throw new Error(`[kuratchi compiler] Browser code cannot use namespace imports from server modules: ${parsed.moduleSpecifier}. Use named imports instead.`);
282
+ }
283
+ for (const binding of parsed.bindings) {
284
+ const importedName = binding.imported;
285
+ const sourceKey = normalizePath(target.resolved);
286
+ const key = `${sourceKey}::${importedName}`;
287
+ if (!this.serverProxyBindings.has(key)) {
288
+ this.serverProxyBindings.set(key, {
289
+ sourceKey,
290
+ moduleSpecifier: target.moduleSpecifier,
291
+ importerDir: path.dirname(resolved),
292
+ importedName,
293
+ rpcId: buildServerProxyRpcId(sourceKey, importedName),
294
+ });
295
+ }
296
+ }
297
+ }
298
+ }
299
+ ensureServerProxyAsset(sourceKey) {
300
+ const cached = this.serverProxyAssets.get(sourceKey);
301
+ if (cached)
302
+ return cached;
303
+ const relFromSrc = path.relative(this.compiler.srcDir, sourceKey).replace(/\\/g, '/');
304
+ const assetName = `__kuratchi/client/routes/${this.routeId}/server/${relFromSrc.replace(/\.(ts|js|mjs|cjs)$/i, '.js')}`;
305
+ const bindings = Array.from(this.serverProxyBindings.values())
306
+ .filter((binding) => binding.sourceKey === sourceKey)
307
+ .sort((a, b) => a.importedName.localeCompare(b.importedName));
308
+ const lines = [
309
+ `function __kuratchiGetCsrf(){`,
310
+ ` return (document.cookie.match(/(?:^|;\\s*)__kuratchi_csrf=([^;]*)/) || [])[1] || '';`,
311
+ `}`,
312
+ `async function __kuratchiCallRemote(rpcId, args){`,
313
+ ` const url = new URL(window.location.pathname, window.location.origin);`,
314
+ ` url.searchParams.set('_rpc', rpcId);`,
315
+ ` if (args.length > 0) url.searchParams.set('_args', JSON.stringify(args));`,
316
+ ` const headers = { 'x-kuratchi-rpc': '1' };`,
317
+ ` const csrfToken = __kuratchiGetCsrf();`,
318
+ ` if (csrfToken) headers['x-kuratchi-csrf'] = csrfToken;`,
319
+ ` const response = await fetch(url.toString(), { method: 'GET', headers });`,
320
+ ` const payload = await response.json().catch(() => ({ ok: false, error: 'Invalid RPC response' }));`,
321
+ ` if (!response.ok || !payload || payload.ok !== true) {`,
322
+ ` throw new Error((payload && payload.error) || ('HTTP ' + response.status));`,
323
+ ` }`,
324
+ ` window.dispatchEvent(new CustomEvent('kuratchi:invalidate-reads', { detail: { rpcId: rpcId } }));`,
325
+ ` return payload.data;`,
326
+ `}`,
327
+ ];
328
+ for (const binding of bindings) {
329
+ if (binding.importedName === 'default') {
330
+ lines.push(`export default async function(...args){ return __kuratchiCallRemote(${JSON.stringify(binding.rpcId)}, args); }`);
331
+ }
332
+ else {
333
+ lines.push(`export async function ${binding.importedName}(...args){ return __kuratchiCallRemote(${JSON.stringify(binding.rpcId)}, args); }`);
334
+ }
335
+ }
336
+ const asset = buildAsset(assetName, lines.join('\n'));
337
+ this.compiler.registerAsset(asset);
338
+ this.serverProxyAssets.set(sourceKey, assetName);
339
+ return assetName;
340
+ }
341
+ transformRouteClientModule(entryAbsPath) {
342
+ const resolved = resolveExistingModuleFile(entryAbsPath) ?? entryAbsPath;
343
+ const normalized = normalizePath(resolved);
344
+ const cached = this.routeModuleAssets.get(normalized);
345
+ if (cached)
346
+ return cached;
347
+ const relFromSrc = path.relative(this.compiler.srcDir, resolved).replace(/\\/g, '/');
348
+ const assetName = `__kuratchi/client/routes/${this.routeId}/modules/${relFromSrc.replace(/\.(ts|js|mjs|cjs)$/i, '.js')}`;
349
+ this.routeModuleAssets.set(normalized, assetName);
350
+ if (!fs.existsSync(resolved)) {
351
+ throw new Error(`[kuratchi compiler] Browser module not found: ${resolved}`);
352
+ }
353
+ const source = fs.readFileSync(resolved, 'utf-8');
354
+ const rewritten = rewriteImportSpecifiers(source, (spec) => {
355
+ const target = resolveClientImportTarget(this.compiler.srcDir, resolved, spec);
356
+ if (target.kind === 'browser') {
357
+ const targetAssetName = this.transformRouteClientModule(target.resolved);
358
+ return toRelativeSpecifier(assetName, targetAssetName);
359
+ }
360
+ const proxyAssetName = this.ensureServerProxyAsset(normalizePath(target.resolved));
361
+ return toRelativeSpecifier(assetName, proxyAssetName);
362
+ });
363
+ this.compiler.registerAsset(buildAsset(assetName, rewritten));
364
+ return assetName;
365
+ }
192
366
  parseClientExpression(expression) {
193
367
  const trimmed = expression.trim();
194
368
  if (!trimmed)
@@ -212,7 +386,6 @@ class CompilerBackedClientModuleCompiler {
212
386
  projectDir;
213
387
  srcDir;
214
388
  compiledAssets = new Map();
215
- transformedModules = new Map();
216
389
  constructor(projectDir, srcDir) {
217
390
  this.projectDir = projectDir;
218
391
  this.srcDir = srcDir;
@@ -229,28 +402,6 @@ class CompilerBackedClientModuleCompiler {
229
402
  registerAsset(asset) {
230
403
  this.compiledAssets.set(asset.name, asset);
231
404
  }
232
- transformClientModule(entryAbsPath) {
233
- const resolved = resolveExistingModuleFile(entryAbsPath) ?? entryAbsPath;
234
- const normalized = resolved.replace(/\\/g, '/');
235
- const cached = this.transformedModules.get(normalized);
236
- if (cached)
237
- return cached;
238
- const relFromSrc = path.relative(this.srcDir, resolved).replace(/\\/g, '/');
239
- const assetName = `__kuratchi/client/modules/${relFromSrc.replace(/\.(ts|js|mjs|cjs)$/i, '.js')}`;
240
- this.transformedModules.set(normalized, assetName);
241
- if (!fs.existsSync(resolved)) {
242
- throw new Error(`[kuratchi compiler] Browser module not found: ${resolved}`);
243
- }
244
- // TypeScript is preserved — wrangler's esbuild handles transpilation
245
- const source = fs.readFileSync(resolved, 'utf-8');
246
- let rewritten = rewriteImportSpecifiers(source, (spec) => {
247
- const targetAbs = resolveClientImportTarget(this.srcDir, resolved, spec);
248
- const targetAssetName = this.transformClientModule(targetAbs);
249
- return toRelativeSpecifier(assetName, targetAssetName);
250
- });
251
- this.registerAsset(buildAsset(assetName, rewritten));
252
- return assetName;
253
- }
254
405
  }
255
406
  export function createClientModuleCompiler(opts) {
256
407
  return new CompilerBackedClientModuleCompiler(opts.projectDir, opts.srcDir);
@@ -61,13 +61,36 @@ export interface DoClassMethodEntry {
61
61
  hasWorkerContextCalls: boolean;
62
62
  callsThisMethods: string[];
63
63
  }
64
+ export interface DoClassContributorEntry {
65
+ /** Absolute path to the contributor source file */
66
+ absPath: string;
67
+ /** Exported class name */
68
+ className: string;
69
+ /** Whether the class is exported as named or default */
70
+ exportKind: 'named' | 'default';
71
+ /** Own methods declared on this contributor */
72
+ classMethods: DoClassMethodEntry[];
73
+ /** Inheritance depth from the base DO class (1 = direct child) */
74
+ depth: number;
75
+ }
76
+ export interface ExportedClassEntry {
77
+ className: string;
78
+ exportKind: 'named' | 'default';
79
+ }
80
+ export interface RelativeImportClassEntry {
81
+ source: string;
82
+ importedName: string | 'default';
83
+ }
64
84
  export interface DoHandlerEntry {
65
85
  fileName: string;
66
86
  absPath: string;
67
87
  binding: string;
68
88
  mode: 'class' | 'function';
69
89
  className?: string;
90
+ exportKind?: 'named' | 'default';
70
91
  classMethods: DoClassMethodEntry[];
92
+ /** Additional exported classes in the same DO folder that extend this base DO class */
93
+ classContributors: DoClassContributorEntry[];
71
94
  exportedFunctions: string[];
72
95
  }
73
96
  export declare function toSafeIdentifier(input: string): string;
@@ -170,7 +170,7 @@ export function readAuthConfig(projectDir) {
170
170
  export function readDoConfig(projectDir) {
171
171
  const configPath = path.join(projectDir, 'kuratchi.config.ts');
172
172
  if (!fs.existsSync(configPath))
173
- return [];
173
+ return readWranglerDoConfig(projectDir);
174
174
  const source = fs.readFileSync(configPath, 'utf-8');
175
175
  const doIdx = source.search(/durableObjects\s*:\s*\{/);
176
176
  if (doIdx === -1)
@@ -229,6 +229,32 @@ export function readDoConfig(projectDir) {
229
229
  }
230
230
  return entries;
231
231
  }
232
+ /** Read durable_objects.bindings from wrangler.jsonc / wrangler.json as fallback. */
233
+ function readWranglerDoConfig(projectDir) {
234
+ const candidates = ['wrangler.jsonc', 'wrangler.json'];
235
+ const wranglerPath = candidates
236
+ .map((file) => path.join(projectDir, file))
237
+ .find((filePath) => fs.existsSync(filePath));
238
+ if (!wranglerPath)
239
+ return [];
240
+ const source = fs.readFileSync(wranglerPath, 'utf-8');
241
+ const bindingsMatch = source.match(/"durable_objects"\s*:\s*\{[\s\S]*?"bindings"\s*:\s*\[([\s\S]*?)\][\s\S]*?\}/);
242
+ if (!bindingsMatch)
243
+ return [];
244
+ const bindingsBody = bindingsMatch[1];
245
+ const entries = [];
246
+ const objectRegex = /\{([\s\S]*?)\}/g;
247
+ let m;
248
+ while ((m = objectRegex.exec(bindingsBody)) !== null) {
249
+ const body = m[1];
250
+ const bindingMatch = body.match(/"name"\s*:\s*"([^"]+)"/);
251
+ const classMatch = body.match(/"class_name"\s*:\s*"([^"]+)"/);
252
+ if (!bindingMatch || !classMatch)
253
+ continue;
254
+ entries.push({ binding: bindingMatch[1], className: classMatch[1] });
255
+ }
256
+ return entries;
257
+ }
232
258
  export function readWorkerClassConfig(projectDir, key) {
233
259
  const configPath = path.join(projectDir, 'kuratchi.config.ts');
234
260
  if (!fs.existsSync(configPath))
@@ -5,5 +5,7 @@ export declare function resolveClassExportFromFile(absPath: string, errorLabel:
5
5
  };
6
6
  export declare function discoverConventionClassFiles(projectDir: string, dir: string, suffix: string, errorLabel: string): ConventionClassEntry[];
7
7
  export declare function discoverFilesWithSuffix(dir: string, suffix: string): string[];
8
+ /** Returns all files in a directory (non-recursive) whose extension is one of the given extensions. */
9
+ export declare function discoverFilesWithExtensions(dir: string, extensions: string[]): string[];
8
10
  export declare function discoverWorkflowFiles(projectDir: string): WorkerClassConfigEntry[];
9
11
  export declare function discoverContainerFiles(projectDir: string): WorkerClassConfigEntry[];
@@ -47,6 +47,22 @@ export function discoverFilesWithSuffix(dir, suffix) {
47
47
  walk(dir);
48
48
  return out;
49
49
  }
50
+ /** Returns all files in a directory (non-recursive) whose extension is one of the given extensions. */
51
+ export function discoverFilesWithExtensions(dir, extensions) {
52
+ if (!fs.existsSync(dir))
53
+ return [];
54
+ const extSet = new Set(extensions.map((e) => e.toLowerCase()));
55
+ const out = [];
56
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
57
+ if (!entry.isFile())
58
+ continue;
59
+ const ext = path.extname(entry.name).toLowerCase();
60
+ if (extSet.has(ext)) {
61
+ out.push(path.join(dir, entry.name));
62
+ }
63
+ }
64
+ return out;
65
+ }
50
66
  export function discoverWorkflowFiles(projectDir) {
51
67
  const serverDir = path.join(projectDir, 'src', 'server');
52
68
  const files = discoverFilesWithSuffix(serverDir, '.workflow.ts');
@@ -6,4 +6,5 @@ export declare function discoverDurableObjects(srcDir: string, configDoEntries:
6
6
  export declare function generateHandlerProxy(handler: DoHandlerEntry, opts: {
7
7
  projectDir: string;
8
8
  runtimeDoImport: string;
9
+ runtimeSchemaImport: string;
9
10
  }): string;