@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.
- package/README.md +193 -7
- package/dist/cli.js +8 -0
- package/dist/compiler/client-module-pipeline.d.ts +8 -0
- package/dist/compiler/client-module-pipeline.js +181 -30
- package/dist/compiler/compiler-shared.d.ts +23 -0
- package/dist/compiler/component-pipeline.js +9 -1
- package/dist/compiler/config-reading.js +27 -1
- package/dist/compiler/convention-discovery.d.ts +2 -0
- package/dist/compiler/convention-discovery.js +16 -0
- package/dist/compiler/durable-object-pipeline.d.ts +1 -0
- package/dist/compiler/durable-object-pipeline.js +459 -119
- package/dist/compiler/index.js +40 -1
- package/dist/compiler/page-route-pipeline.js +31 -2
- package/dist/compiler/parser.d.ts +1 -0
- package/dist/compiler/parser.js +47 -4
- package/dist/compiler/root-layout-pipeline.js +18 -3
- package/dist/compiler/route-pipeline.d.ts +2 -0
- package/dist/compiler/route-pipeline.js +19 -3
- package/dist/compiler/routes-module-feature-blocks.js +143 -17
- package/dist/compiler/routes-module-types.d.ts +1 -0
- package/dist/compiler/server-module-pipeline.js +24 -0
- package/dist/compiler/template.d.ts +4 -0
- package/dist/compiler/template.js +50 -18
- package/dist/compiler/type-generator.d.ts +8 -0
- package/dist/compiler/type-generator.js +124 -0
- package/dist/compiler/worker-output-pipeline.js +2 -0
- package/dist/compiler/wrangler-sync.d.ts +3 -0
- package/dist/compiler/wrangler-sync.js +25 -11
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/runtime/context.d.ts +9 -1
- package/dist/runtime/context.js +25 -2
- package/dist/runtime/generated-worker.d.ts +1 -0
- package/dist/runtime/generated-worker.js +11 -7
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/navigation.d.ts +8 -0
- package/dist/runtime/navigation.js +8 -0
- package/dist/runtime/request.d.ts +28 -0
- package/dist/runtime/request.js +44 -0
- package/dist/runtime/schema.d.ts +49 -0
- package/dist/runtime/schema.js +148 -0
- package/dist/runtime/types.d.ts +2 -0
- package/dist/runtime/validation.d.ts +26 -0
- package/dist/runtime/validation.js +147 -0
- 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` —
|
|
400
|
+
### `data-get` — query blocks and refreshable reads
|
|
345
401
|
|
|
346
|
-
|
|
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
|
-
<
|
|
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
|
|
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 '
|
|
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
|
|
691
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
174
|
-
|
|
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
|
|
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);
|