@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.
- package/README.md +172 -9
- 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/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/import-linking.js +1 -1
- package/dist/compiler/index.js +41 -2
- 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 +26 -1
- package/dist/compiler/route-discovery.js +5 -5
- package/dist/compiler/route-pipeline.d.ts +2 -0
- package/dist/compiler/route-pipeline.js +28 -4
- package/dist/compiler/routes-module-feature-blocks.js +149 -17
- package/dist/compiler/routes-module-types.d.ts +1 -0
- package/dist/compiler/template.d.ts +4 -0
- package/dist/compiler/template.js +50 -18
- 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/create.js +6 -6
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/runtime/context.d.ts +6 -0
- package/dist/runtime/context.js +22 -1
- 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/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 +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/
|
|
46
|
-
src/routes/items/
|
|
47
|
-
src/routes/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]/
|
|
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.
|
|
@@ -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
|
|
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
|
|
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;
|
|
@@ -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');
|