@kuratchi/js 0.0.19 → 0.0.21

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 (46) hide show
  1. package/README.md +193 -7
  2. package/dist/cli.js +8 -0
  3. package/dist/compiler/client-module-pipeline.d.ts +8 -0
  4. package/dist/compiler/client-module-pipeline.js +181 -30
  5. package/dist/compiler/compiler-shared.d.ts +23 -0
  6. package/dist/compiler/component-pipeline.js +9 -1
  7. package/dist/compiler/config-reading.js +27 -1
  8. package/dist/compiler/convention-discovery.d.ts +2 -0
  9. package/dist/compiler/convention-discovery.js +16 -0
  10. package/dist/compiler/durable-object-pipeline.d.ts +1 -0
  11. package/dist/compiler/durable-object-pipeline.js +459 -119
  12. package/dist/compiler/index.js +40 -1
  13. package/dist/compiler/page-route-pipeline.js +31 -2
  14. package/dist/compiler/parser.d.ts +1 -0
  15. package/dist/compiler/parser.js +47 -4
  16. package/dist/compiler/root-layout-pipeline.js +18 -3
  17. package/dist/compiler/route-pipeline.d.ts +2 -0
  18. package/dist/compiler/route-pipeline.js +19 -3
  19. package/dist/compiler/routes-module-feature-blocks.js +143 -17
  20. package/dist/compiler/routes-module-types.d.ts +1 -0
  21. package/dist/compiler/server-module-pipeline.js +24 -0
  22. package/dist/compiler/template.d.ts +4 -0
  23. package/dist/compiler/template.js +50 -18
  24. package/dist/compiler/type-generator.d.ts +8 -0
  25. package/dist/compiler/type-generator.js +124 -0
  26. package/dist/compiler/worker-output-pipeline.js +2 -0
  27. package/dist/compiler/wrangler-sync.d.ts +3 -0
  28. package/dist/compiler/wrangler-sync.js +25 -11
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.js +1 -0
  31. package/dist/runtime/context.d.ts +9 -1
  32. package/dist/runtime/context.js +25 -2
  33. package/dist/runtime/generated-worker.d.ts +1 -0
  34. package/dist/runtime/generated-worker.js +11 -7
  35. package/dist/runtime/index.d.ts +2 -0
  36. package/dist/runtime/index.js +1 -0
  37. package/dist/runtime/navigation.d.ts +8 -0
  38. package/dist/runtime/navigation.js +8 -0
  39. package/dist/runtime/request.d.ts +28 -0
  40. package/dist/runtime/request.js +44 -0
  41. package/dist/runtime/schema.d.ts +49 -0
  42. package/dist/runtime/schema.js +148 -0
  43. package/dist/runtime/types.d.ts +2 -0
  44. package/dist/runtime/validation.d.ts +26 -0
  45. package/dist/runtime/validation.js +147 -0
  46. package/package.json +76 -68
package/README.md CHANGED
@@ -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.
@@ -278,6 +332,8 @@ Throw `PageError` from a route's load scope to return the correct HTTP error pag
278
332
  import { PageError } from '@kuratchi/js';
279
333
 
280
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.
@@ -671,24 +831,50 @@ import {
671
831
  } from '@kuratchi/js';
672
832
  ```
673
833
 
834
+ ### Virtual Modules
835
+
836
+ Kuratchi provides virtual modules for request-scoped state. Use these in route files:
837
+
838
+ | Virtual Module | Description |
839
+ |----------------|-------------|
840
+ | `kuratchi:request` | Request state: `url`, `params`, `searchParams`, `headers`, `locals`, etc. |
841
+ | `kuratchi:navigation` | Server-side redirect helper |
842
+
674
843
  ### Request helpers
675
844
 
676
- For a batteries-included request layer, import pre-parsed request state from `@kuratchi/js/request`:
845
+ For a batteries-included request layer, import pre-parsed request state from `kuratchi:request`:
677
846
 
678
847
  ```ts
679
- import { url, pathname, searchParams, slug } from '@kuratchi/js/request';
848
+ import { url, pathname, searchParams, params, slug, locals } from 'kuratchi:request';
680
849
 
681
850
  const page = pathname;
682
851
  const tab = searchParams.get('tab');
852
+ const postId = params.id;
683
853
  const postSlug = slug;
684
854
  ```
685
855
 
686
856
  - `url` is the parsed `URL` for the current request.
687
857
  - `pathname` is the full path, like `/blog/hello-world`.
688
858
  - `searchParams` is `url.searchParams` for the current request.
859
+ - `params` is the matched route params object, like `{ slug: 'hello-world' }`.
689
860
  - `slug` is `params.slug` when the matched route defines a `slug` param.
690
- - `headers`, `method`, and `params` are also exported from `@kuratchi/js/request`.
691
- - Use `getRequest()` when you want the raw native `Request` object.
861
+ - `headers` and `method` are also exported from `kuratchi:request`.
862
+ - `locals` is the request-scoped locals object (typed via `App.Locals` in `app.d.ts`).
863
+ - Use `getRequest()` from `@kuratchi/js` when you want the raw native `Request` object.
864
+
865
+ ### Server-side redirect
866
+
867
+ Import `redirect` from `kuratchi:navigation` for server-side redirects:
868
+
869
+ ```ts
870
+ import { redirect } from 'kuratchi:navigation';
871
+
872
+ // Redirect to another page (throws RedirectError, caught by framework)
873
+ redirect('/dashboard');
874
+ redirect('/login', 302);
875
+ ```
876
+
877
+ `redirect()` works in route scripts, `$server/` modules, and form actions. It throws a `RedirectError` that the framework catches and converts to a proper HTTP redirect response (default 303 for POST-Redirect-GET).
692
878
 
693
879
  ## Runtime Hook
694
880
 
package/dist/cli.js CHANGED
@@ -29,6 +29,9 @@ async function main() {
29
29
  case 'create':
30
30
  await runCreate();
31
31
  return;
32
+ case 'types':
33
+ await runTypes();
34
+ return;
32
35
  default:
33
36
  console.log(`
34
37
  KuratchiJS CLI
@@ -38,6 +41,7 @@ Usage:
38
41
  kuratchi build Compile routes once
39
42
  kuratchi dev Compile, watch for changes, and start wrangler dev server
40
43
  kuratchi watch Compile + watch only (no wrangler — for custom setups)
44
+ kuratchi types Generate TypeScript types from schema to src/app.d.ts
41
45
  `);
42
46
  process.exit(1);
43
47
  }
@@ -49,6 +53,10 @@ async function runCreate() {
49
53
  const positional = remaining.filter(a => !a.startsWith('-'));
50
54
  await create(positional[0], flags);
51
55
  }
56
+ async function runTypes() {
57
+ const { writeAppTypes } = await import('./compiler/type-generator.js');
58
+ writeAppTypes({ projectDir });
59
+ }
52
60
  async function runBuild(isDev = false) {
53
61
  console.log('[kuratchi] Compiling...');
54
62
  try {
@@ -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;
@@ -97,7 +97,15 @@ export function createComponentCompiler(options) {
97
97
  const scopeOpen = `__parts.push('<div class="${scopeHash}">');`;
98
98
  const scopeClose = `__parts.push('</div>');`;
99
99
  const bodyLines = body.split('\n');
100
- const scopedBody = [bodyLines[0], scopeOpen, ...bodyLines.slice(1), scopeClose].join('\n');
100
+ const insertIndex = bodyLines.findIndex(l => l.startsWith('let __html'));
101
+ const safeInsertIndex = insertIndex === -1 ? bodyLines.length : insertIndex;
102
+ const scopedBody = [
103
+ bodyLines[0],
104
+ scopeOpen,
105
+ ...bodyLines.slice(1, safeInsertIndex),
106
+ scopeClose,
107
+ ...bodyLines.slice(safeInsertIndex)
108
+ ].join('\n');
101
109
  const fnBody = effectivePropsCode ? `${effectivePropsCode}\n ${scopedBody}` : scopedBody;
102
110
  const compiled = `function ${funcName}(props, __esc) {\n ${fnBody}\n return __html;\n}`;
103
111
  compiledComponentCache.set(fileName, compiled);