@kuratchi/js 0.0.3 → 0.0.4

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 CHANGED
@@ -18,12 +18,13 @@ bun run dev
18
18
 
19
19
  ## How it works
20
20
 
21
- `kuratchi build` (or `kuratchi watch`) scans `src/routes/` and generates two files:
21
+ `kuratchi build` (or `kuratchi watch`) scans `src/routes/` and generates framework output:
22
22
 
23
23
  | File | Purpose |
24
24
  |---|---|
25
25
  | `.kuratchi/routes.js` | Compiled routes, actions, RPC handlers, and render functions |
26
- | `.kuratchi/worker.js` | Stable wrangler entry re-exports the fetch handler and all Durable Object classes |
26
+ | `.kuratchi/worker.js` | Stable wrangler entry - re-exports the fetch handler and all Durable Object classes |
27
+ | `.kuratchi/do/*.js` | Generated Durable Object RPC proxy modules for `$durable-objects/*` imports |
27
28
 
28
29
  Point wrangler at the entry and you're done. **No `src/index.ts` needed.**
29
30
 
@@ -95,7 +96,8 @@ The `$database/` alias resolves to `src/database/`. You can use any path alias c
95
96
 
96
97
  ```html
97
98
  <p>{title}</p>
98
- <p>{=html rawHtml}</p> <!-- unescaped, use carefully -->
99
+ <p>{@html bodyHtml}</p> <!-- sanitized HTML -->
100
+ <p>{@raw trustedHtml}</p> <!-- unescaped, unsafe -->
99
101
  ```
100
102
 
101
103
  ### Conditionals
@@ -131,6 +133,40 @@ Import `.html` components from your `src/lib/` directory or from packages:
131
133
  </Card>
132
134
  ```
133
135
 
136
+ ### Client Reactivity (`$:`)
137
+
138
+ Inside client/browser `<script>` tags in the template markup, Kuratchi supports Svelte-style reactive labels:
139
+
140
+ ```html
141
+ <script>
142
+ let users = ['Alice'];
143
+
144
+ $: console.log(`Users: ${users.length}`);
145
+
146
+ function addUser() {
147
+ users.push('Bob'); // reactive update, no reassignment required
148
+ }
149
+ </script>
150
+ ```
151
+
152
+ Block form is also supported:
153
+
154
+ ```html
155
+ <script>
156
+ let form = { first: '', last: '' };
157
+
158
+ $: {
159
+ const fullName = `${form.first} ${form.last}`.trim();
160
+ console.log(fullName);
161
+ }
162
+ </script>
163
+ ```
164
+
165
+ Notes:
166
+ - This reactivity runs in browser scripts rendered in the template markup, not in the top server route `<script>` load/action block.
167
+ - Object/array `let` bindings are proxy-backed automatically when `$:` is used.
168
+ - `$: name = expr` works; when replacing proxy-backed values, the compiler preserves reactivity under the hood.
169
+
134
170
  ## Form actions
135
171
 
136
172
  Export server functions from a route's `<script>` block and reference them with `action={fn}`. The compiler automatically registers them as dispatchable actions.
@@ -147,12 +183,15 @@ Export server functions from a route's `<script>` block and reference them with
147
183
  </form>
148
184
  ```
149
185
 
150
- The action function receives the raw `FormData`:
186
+ The action function receives the raw `FormData`. Throw `ActionError` to surface a message back to the form — see [Error handling](#error-handling).
151
187
 
152
188
  ```ts
153
189
  // src/database/items.ts
190
+ import { ActionError } from '@kuratchi/js';
191
+
154
192
  export async function addItem(formData: FormData): Promise<void> {
155
- const title = formData.get('title') as string;
193
+ const title = (formData.get('title') as string)?.trim();
194
+ if (!title) throw new ActionError('Title is required');
156
195
  // write to DB...
157
196
  }
158
197
  ```
@@ -170,6 +209,85 @@ export async function createItem(formData: FormData): Promise<void> {
170
209
  }
171
210
  ```
172
211
 
212
+ ## Error handling
213
+
214
+ ### Action errors
215
+
216
+ Throw `ActionError` from a form action to surface a user-facing message in the template. The error message is bound directly to the action by name — if you have multiple forms on the same page, each has its own isolated error state.
217
+
218
+ ```ts
219
+ import { ActionError } from '@kuratchi/js';
220
+
221
+ export async function signIn(formData: FormData) {
222
+ const email = formData.get('email') as string;
223
+ const password = formData.get('password') as string;
224
+
225
+ if (!email || !password) throw new ActionError('Email and password are required');
226
+
227
+ const user = await db.findUser(email);
228
+ if (!user || !await verify(password, user.passwordHash)) {
229
+ throw new ActionError('Invalid credentials');
230
+ }
231
+ }
232
+ ```
233
+
234
+ In the template, the action's state object is available under its function name:
235
+
236
+ ```html
237
+ <script>
238
+ import { signIn } from '$database/auth';
239
+ </script>
240
+
241
+ <form action={signIn}>
242
+ (signIn.error ? `<p class="error">${signIn.error}</p>` : '')
243
+ <input type="email" name="email" />
244
+ <input type="password" name="password" />
245
+ <button type="submit">Sign in</button>
246
+ </form>
247
+ ```
248
+
249
+ The state object shape: `{ error?: string, loading: boolean, success: boolean }`.
250
+
251
+ - `actionName.error` — set on `ActionError` throw, cleared on next successful action
252
+ - `actionName.loading` — set by the client bridge during form submission (CSS target: `form[data-action-loading]`)
253
+ - `actionName.success` — reserved for future use
254
+
255
+ Throwing a plain `Error` instead of `ActionError` keeps the message hidden in production and shows a generic "Action failed" message. Use `ActionError` for expected validation failures; let plain errors propagate for unexpected crashes.
256
+
257
+ ### Load errors
258
+
259
+ Throw `PageError` from a route's load scope to return the correct HTTP error page. Without it, any thrown error becomes a 500.
260
+
261
+ ```ts
262
+ import { PageError } from '@kuratchi/js';
263
+
264
+ // In src/routes/posts/[id]/page.html <script> block:
265
+ const post = await db.posts.findOne({ id: params.id });
266
+ if (!post) throw new PageError(404);
267
+ if (!post.isPublished && !currentUser?.isAdmin) throw new PageError(403);
268
+ ```
269
+
270
+ `PageError` accepts any HTTP status. The framework renders the matching custom error page (`src/routes/404.html`, `src/routes/500.html`, etc.) if one exists, otherwise falls back to the built-in error page.
271
+
272
+ ```ts
273
+ throw new PageError(404); // → 404 page
274
+ throw new PageError(403, 'Admin only'); // → 403 page, message shown in dev
275
+ throw new PageError(401, 'Login required'); // → 401 page
276
+ ```
277
+
278
+ For soft load failures where the page should still render (e.g. a widget that failed to fetch), return the error as data from `load()` and handle it in the template:
279
+
280
+ ```html
281
+ <script>
282
+ const { data: recommendations, error: recError } = await safeGetRecommendations();
283
+ </script>
284
+
285
+ (recError ? '<p class="notice">Could not load recommendations.</p>' : '')
286
+ for (const rec of (recommendations ?? [])) {
287
+ <article>{rec.title}</article>
288
+ }
289
+ ```
290
+
173
291
  ## Progressive enhancement
174
292
 
175
293
  These `data-*` attributes wire up client-side interactivity without writing JavaScript.
@@ -239,53 +357,93 @@ for (const todo of todos) {
239
357
 
240
358
  ## RPC
241
359
 
242
- Export an `rpc` object from a route to expose server functions callable from client-side JavaScript. RPC names are replaced with opaque IDs at compile time — real function names are never exposed to the client.
360
+ For Durable Objects, RPC is file-driven and automatic.
361
+
362
+ - Put handler logic in a `.do.ts` file.
363
+ - Exported functions in that file become RPC methods.
364
+ - Import RPC methods from `$durable-objects/<file-name-without-.do>`.
243
365
 
244
366
  ```html
245
367
  <script>
246
- import { getCount } from '$database/items';
247
-
248
- export const rpc = {
249
- getCount,
250
- };
368
+ import { getOrgUsers, createOrgUser } from '$durable-objects/auth';
369
+ const users = await getOrgUsers();
251
370
  </script>
371
+
372
+ <form action={createOrgUser} method="POST">
373
+ <input type="email" name="email" required />
374
+ <button type="submit">Create</button>
375
+ </form>
252
376
  ```
377
+ ## Durable Objects
253
378
 
254
- Call from the client using the generated `rpc` helper:
379
+ Durable Object behavior is enabled by filename suffix.
255
380
 
256
- ```html
257
- <script client>
258
- const result = await rpc.getCount();
259
- </script>
381
+ - Any file ending in `.do.ts` is treated as a Durable Object handler file.
382
+ - Any file not ending in `.do.ts` is treated as a normal server module.
383
+ - No required folder name. `src/server/auth.do.ts`, `src/server/foo/bar/sites.do.ts`, etc. all work.
384
+
385
+ ### Function mode (recommended)
386
+
387
+ Write plain exported functions in a `.do.ts` file. Exported functions become DO RPC methods.
388
+ Use `this.db`, `this.env`, and `this.ctx` inside those functions.
389
+
390
+ ```ts
391
+ // src/server/auth/auth.do.ts
392
+ import { getCurrentUser, hashPassword } from '@kuratchi/auth';
393
+ import { redirect } from '@kuratchi/js';
394
+
395
+ async function randomPassword(length = 24): Promise<string> {
396
+ const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
397
+ const bytes = new Uint8Array(length);
398
+ crypto.getRandomValues(bytes);
399
+ let out = '';
400
+ for (let i = 0; i < length; i++) out += alphabet[bytes[i] % alphabet.length];
401
+ return out;
402
+ }
403
+
404
+ export async function getOrgUsers() {
405
+ const result = await this.db.users.orderBy({ createdAt: 'asc' }).many();
406
+ return result.data ?? [];
407
+ }
408
+
409
+ export async function createOrgUser(formData: FormData) {
410
+ const user = await getCurrentUser();
411
+ if (!user?.orgId) throw new Error('Not authenticated');
412
+
413
+ const email = String(formData.get('email') ?? '').trim().toLowerCase();
414
+ if (!email) throw new Error('Email is required');
415
+
416
+ const passwordHash = await hashPassword(await randomPassword(), undefined, this.env.AUTH_SECRET);
417
+ await this.db.users.insert({ email, role: 'member', passwordHash });
418
+ redirect('/settings/users');
419
+ }
260
420
  ```
261
421
 
262
- ## Durable Objects
422
+ Optional lifecycle exports in function mode:
263
423
 
264
- Extend `kuratchiDO` to create a Durable Object class backed by SQLite. The `static binding` name must match the binding in `wrangler.jsonc`.
424
+ - `export async function onInit()`
425
+ - `export async function onAlarm(...args)`
426
+ - `export function onMessage(...args)`
427
+
428
+ These lifecycle names are not exposed as RPC methods.
429
+
430
+ ### Class mode (optional)
431
+
432
+ Class-based handlers are still supported in `.do.ts` files:
265
433
 
266
434
  ```ts
267
- // src/server/notes.do.ts
268
435
  import { kuratchiDO } from '@kuratchi/js';
269
- import type { Note } from '../schemas/notes';
270
436
 
271
437
  export default class NotesDO extends kuratchiDO {
272
438
  static binding = 'NOTES_DO';
273
439
 
274
- async getNotes(): Promise<Note[]> {
440
+ async getNotes() {
275
441
  return (await this.db.notes.orderBy({ created_at: 'desc' }).many()).data ?? [];
276
442
  }
277
-
278
- async addNote(title: string): Promise<void> {
279
- await this.db.notes.insert({ title });
280
- }
281
-
282
- async deleteNote(id: number): Promise<void> {
283
- await this.db.notes.delete({ id });
284
- }
285
443
  }
286
444
  ```
287
445
 
288
- Declare it in `kuratchi.config.ts` and in `wrangler.jsonc`. The compiler exports it from `.kuratchi/worker.js` automatically.
446
+ Declare it in `kuratchi.config.ts` and in `wrangler.jsonc`. The compiler exports DO classes from `.kuratchi/worker.js` automatically.
289
447
 
290
448
  ```jsonc
291
449
  // wrangler.jsonc
@@ -298,22 +456,6 @@ Declare it in `kuratchi.config.ts` and in `wrangler.jsonc`. The compiler exports
298
456
  ]
299
457
  }
300
458
  ```
301
-
302
- Call DO methods from your route via a stub module in `src/database/`:
303
-
304
- ```ts
305
- // src/database/notes.ts
306
- import { env } from 'cloudflare:workers';
307
-
308
- function getStub() {
309
- return (env as any).NOTES_DO.get((env as any).NOTES_DO.idFromName('global'));
310
- }
311
-
312
- export const getNotes = () => getStub().getNotes();
313
- export const addNote = (title: string) => getStub().addNote(title);
314
- export const deleteNote = (id: number) => getStub().deleteNote(id);
315
- ```
316
-
317
459
  ## Runtime APIs
318
460
 
319
461
  These are available anywhere in server-side route code:
@@ -357,7 +499,10 @@ export default defineConfig({
357
499
  },
358
500
  }),
359
501
  durableObjects: {
360
- NOTES_DO: { className: 'NotesDO' },
502
+ NOTES_DO: {
503
+ className: 'NotesDO',
504
+ files: ['notes.do.ts'],
505
+ },
361
506
  },
362
507
  auth: kuratchiAuthConfig({
363
508
  cookieName: 'kuratchi_session',
@@ -375,6 +520,20 @@ npx kuratchi build # one-shot build
375
520
  npx kuratchi watch # watch mode (for use with wrangler dev)
376
521
  ```
377
522
 
523
+ ## Testing the Framework
524
+
525
+ Run framework tests from `packages/kuratchi-js`:
526
+
527
+ ```bash
528
+ bun run test
529
+ ```
530
+
531
+ Watch mode:
532
+
533
+ ```bash
534
+ bun run test:watch
535
+ ```
536
+
378
537
  ## TypeScript & Worker types
379
538
 
380
539
  ```bash
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { compile } from './compiler/index.js';
6
6
  import * as path from 'node:path';
7
7
  import * as fs from 'node:fs';
8
+ import * as net from 'node:net';
8
9
  import { spawn } from 'node:child_process';
9
10
  const args = process.argv.slice(2);
10
11
  const command = args[0];
@@ -79,24 +80,65 @@ function runWatch(withWrangler = false) {
79
80
  // `kuratchi dev` also starts the wrangler dev server.
80
81
  // `kuratchi watch` is the compiler-only mode for custom setups.
81
82
  if (withWrangler) {
82
- const wranglerArgs = ['wrangler', 'dev', '--port', '8787'];
83
- const wrangler = spawn('npx', wranglerArgs, {
84
- cwd: projectDir,
85
- stdio: 'inherit',
86
- shell: process.platform === 'win32',
83
+ startWranglerDev().catch((err) => {
84
+ console.error(`[kuratchi] Failed to start wrangler dev: ${err?.message ?? err}`);
85
+ process.exit(1);
87
86
  });
88
- const cleanup = () => {
89
- if (!wrangler.killed)
90
- wrangler.kill();
91
- };
92
- process.on('exit', cleanup);
93
- process.on('SIGINT', () => { cleanup(); process.exit(0); });
94
- process.on('SIGTERM', () => { cleanup(); process.exit(0); });
95
- wrangler.on('exit', (code) => {
96
- if (code !== 0 && code !== null) {
97
- console.error(`[kuratchi] wrangler exited with code ${code}`);
98
- }
99
- process.exit(code ?? 0);
87
+ }
88
+ }
89
+ function hasPortFlag(inputArgs) {
90
+ for (let i = 0; i < inputArgs.length; i++) {
91
+ const arg = inputArgs[i];
92
+ if (arg === '--port' || arg === '-p')
93
+ return true;
94
+ if (arg.startsWith('--port='))
95
+ return true;
96
+ if (arg.startsWith('-p='))
97
+ return true;
98
+ }
99
+ return false;
100
+ }
101
+ function isPortAvailable(port) {
102
+ return new Promise((resolve) => {
103
+ const server = net.createServer();
104
+ server.once('error', () => resolve(false));
105
+ server.once('listening', () => {
106
+ server.close(() => resolve(true));
100
107
  });
108
+ server.listen(port, '127.0.0.1');
109
+ });
110
+ }
111
+ async function findOpenPort(start = 8787, end = 8899) {
112
+ for (let port = start; port <= end; port++) {
113
+ if (await isPortAvailable(port))
114
+ return port;
115
+ }
116
+ throw new Error(`No open dev port found in range ${start}-${end}`);
117
+ }
118
+ async function startWranglerDev() {
119
+ const passthroughArgs = args.slice(1);
120
+ const wranglerArgs = ['wrangler', 'dev', ...passthroughArgs];
121
+ if (!hasPortFlag(passthroughArgs)) {
122
+ const port = await findOpenPort();
123
+ wranglerArgs.push('--port', String(port));
124
+ console.log(`[kuratchi] Starting wrangler dev on port ${port}`);
101
125
  }
126
+ const wrangler = spawn('npx', wranglerArgs, {
127
+ cwd: projectDir,
128
+ stdio: 'inherit',
129
+ shell: process.platform === 'win32',
130
+ });
131
+ const cleanup = () => {
132
+ if (!wrangler.killed)
133
+ wrangler.kill();
134
+ };
135
+ process.on('exit', cleanup);
136
+ process.on('SIGINT', () => { cleanup(); process.exit(0); });
137
+ process.on('SIGTERM', () => { cleanup(); process.exit(0); });
138
+ wrangler.on('exit', (code) => {
139
+ if (code !== 0 && code !== null) {
140
+ console.error(`[kuratchi] wrangler exited with code ${code}`);
141
+ }
142
+ process.exit(code ?? 0);
143
+ });
102
144
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Compiler — scans a project's routes/ directory, parses .html files,
2
+ * Compiler â€" scans a project's routes/ directory, parses .html files,
3
3
  * and generates a single Worker entry point.
4
4
  */
5
5
  export { parseFile } from './parser.js';
@@ -27,7 +27,7 @@ export interface CompiledRoute {
27
27
  /**
28
28
  * Compile a project's src/routes/ into .kuratchi/routes.js
29
29
  *
30
- * The generated module exports { app } — an object with a fetch() method
30
+ * The generated module exports { app } â€" an object with a fetch() method
31
31
  * that handles routing, load functions, form actions, and rendering.
32
32
  * Returns the path to .kuratchi/worker.js — the stable wrangler entry point that
33
33
  * re-exports everything from routes.js (default fetch handler + named DO class exports).