@kuratchi/js 0.0.3 → 0.0.5

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
 
@@ -49,8 +50,6 @@ src/routes/layout.html → shared layout wrapping all routes
49
50
 
50
51
  ```html
51
52
  <script>
52
- // Imports and server-side logic run on every request.
53
- // Exported functions become actions or RPC handlers automatically.
54
53
  import { getItems, addItem, deleteItem } from '$database/items';
55
54
 
56
55
  const items = await getItems();
@@ -95,7 +94,8 @@ The `$database/` alias resolves to `src/database/`. You can use any path alias c
95
94
 
96
95
  ```html
97
96
  <p>{title}</p>
98
- <p>{=html rawHtml}</p> <!-- unescaped, use carefully -->
97
+ <p>{@html bodyHtml}</p> <!-- sanitized HTML -->
98
+ <p>{@raw trustedHtml}</p> <!-- unescaped, unsafe -->
99
99
  ```
100
100
 
101
101
  ### Conditionals
@@ -131,6 +131,40 @@ Import `.html` components from your `src/lib/` directory or from packages:
131
131
  </Card>
132
132
  ```
133
133
 
134
+ ### Client Reactivity (`$:`)
135
+
136
+ Inside client/browser `<script>` tags in the template markup, Kuratchi supports Svelte-style reactive labels:
137
+
138
+ ```html
139
+ <script>
140
+ let users = ['Alice'];
141
+
142
+ $: console.log(`Users: ${users.length}`);
143
+
144
+ function addUser() {
145
+ users.push('Bob'); // reactive update, no reassignment required
146
+ }
147
+ </script>
148
+ ```
149
+
150
+ Block form is also supported:
151
+
152
+ ```html
153
+ <script>
154
+ let form = { first: '', last: '' };
155
+
156
+ $: {
157
+ const fullName = `${form.first} ${form.last}`.trim();
158
+ console.log(fullName);
159
+ }
160
+ </script>
161
+ ```
162
+
163
+ Notes:
164
+ - This reactivity runs in browser scripts rendered in the template markup, not in the top server route `<script>` load/action block.
165
+ - Object/array `let` bindings are proxy-backed automatically when `$:` is used.
166
+ - `$: name = expr` works; when replacing proxy-backed values, the compiler preserves reactivity under the hood.
167
+
134
168
  ## Form actions
135
169
 
136
170
  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 +181,15 @@ Export server functions from a route's `<script>` block and reference them with
147
181
  </form>
148
182
  ```
149
183
 
150
- The action function receives the raw `FormData`:
184
+ The action function receives the raw `FormData`. Throw `ActionError` to surface a message back to the form — see [Error handling](#error-handling).
151
185
 
152
186
  ```ts
153
187
  // src/database/items.ts
188
+ import { ActionError } from '@kuratchi/js';
189
+
154
190
  export async function addItem(formData: FormData): Promise<void> {
155
- const title = formData.get('title') as string;
191
+ const title = (formData.get('title') as string)?.trim();
192
+ if (!title) throw new ActionError('Title is required');
156
193
  // write to DB...
157
194
  }
158
195
  ```
@@ -170,6 +207,85 @@ export async function createItem(formData: FormData): Promise<void> {
170
207
  }
171
208
  ```
172
209
 
210
+ ## Error handling
211
+
212
+ ### Action errors
213
+
214
+ 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.
215
+
216
+ ```ts
217
+ import { ActionError } from '@kuratchi/js';
218
+
219
+ export async function signIn(formData: FormData) {
220
+ const email = formData.get('email') as string;
221
+ const password = formData.get('password') as string;
222
+
223
+ if (!email || !password) throw new ActionError('Email and password are required');
224
+
225
+ const user = await db.findUser(email);
226
+ if (!user || !await verify(password, user.passwordHash)) {
227
+ throw new ActionError('Invalid credentials');
228
+ }
229
+ }
230
+ ```
231
+
232
+ In the template, the action's state object is available under its function name:
233
+
234
+ ```html
235
+ <script>
236
+ import { signIn } from '$database/auth';
237
+ </script>
238
+
239
+ <form action={signIn}>
240
+ (signIn.error ? `<p class="error">${signIn.error}</p>` : '')
241
+ <input type="email" name="email" />
242
+ <input type="password" name="password" />
243
+ <button type="submit">Sign in</button>
244
+ </form>
245
+ ```
246
+
247
+ The state object shape: `{ error?: string, loading: boolean, success: boolean }`.
248
+
249
+ - `actionName.error` — set on `ActionError` throw, cleared on next successful action
250
+ - `actionName.loading` — set by the client bridge during form submission (CSS target: `form[data-action-loading]`)
251
+ - `actionName.success` — reserved for future use
252
+
253
+ 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.
254
+
255
+ ### Load errors
256
+
257
+ Throw `PageError` from a route's load scope to return the correct HTTP error page. Without it, any thrown error becomes a 500.
258
+
259
+ ```ts
260
+ import { PageError } from '@kuratchi/js';
261
+
262
+ // In src/routes/posts/[id]/page.html <script> block:
263
+ const post = await db.posts.findOne({ id: params.id });
264
+ if (!post) throw new PageError(404);
265
+ if (!post.isPublished && !currentUser?.isAdmin) throw new PageError(403);
266
+ ```
267
+
268
+ `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.
269
+
270
+ ```ts
271
+ throw new PageError(404); // → 404 page
272
+ throw new PageError(403, 'Admin only'); // → 403 page, message shown in dev
273
+ throw new PageError(401, 'Login required'); // → 401 page
274
+ ```
275
+
276
+ 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:
277
+
278
+ ```html
279
+ <script>
280
+ const { data: recommendations, error: recError } = await safeGetRecommendations();
281
+ </script>
282
+
283
+ (recError ? '<p class="notice">Could not load recommendations.</p>' : '')
284
+ for (const rec of (recommendations ?? [])) {
285
+ <article>{rec.title}</article>
286
+ }
287
+ ```
288
+
173
289
  ## Progressive enhancement
174
290
 
175
291
  These `data-*` attributes wire up client-side interactivity without writing JavaScript.
@@ -239,53 +355,93 @@ for (const todo of todos) {
239
355
 
240
356
  ## RPC
241
357
 
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.
358
+ For Durable Objects, RPC is file-driven and automatic.
359
+
360
+ - Put handler logic in a `.do.ts` file.
361
+ - Exported functions in that file become RPC methods.
362
+ - Import RPC methods from `$durable-objects/<file-name-without-.do>`.
243
363
 
244
364
  ```html
245
365
  <script>
246
- import { getCount } from '$database/items';
247
-
248
- export const rpc = {
249
- getCount,
250
- };
366
+ import { getOrgUsers, createOrgUser } from '$durable-objects/auth';
367
+ const users = await getOrgUsers();
251
368
  </script>
369
+
370
+ <form action={createOrgUser} method="POST">
371
+ <input type="email" name="email" required />
372
+ <button type="submit">Create</button>
373
+ </form>
252
374
  ```
375
+ ## Durable Objects
253
376
 
254
- Call from the client using the generated `rpc` helper:
377
+ Durable Object behavior is enabled by filename suffix.
255
378
 
256
- ```html
257
- <script client>
258
- const result = await rpc.getCount();
259
- </script>
379
+ - Any file ending in `.do.ts` is treated as a Durable Object handler file.
380
+ - Any file not ending in `.do.ts` is treated as a normal server module.
381
+ - No required folder name. `src/server/auth.do.ts`, `src/server/foo/bar/sites.do.ts`, etc. all work.
382
+
383
+ ### Function mode (recommended)
384
+
385
+ Write plain exported functions in a `.do.ts` file. Exported functions become DO RPC methods.
386
+ Use `this.db`, `this.env`, and `this.ctx` inside those functions.
387
+
388
+ ```ts
389
+ // src/server/auth/auth.do.ts
390
+ import { getCurrentUser, hashPassword } from '@kuratchi/auth';
391
+ import { redirect } from '@kuratchi/js';
392
+
393
+ async function randomPassword(length = 24): Promise<string> {
394
+ const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
395
+ const bytes = new Uint8Array(length);
396
+ crypto.getRandomValues(bytes);
397
+ let out = '';
398
+ for (let i = 0; i < length; i++) out += alphabet[bytes[i] % alphabet.length];
399
+ return out;
400
+ }
401
+
402
+ export async function getOrgUsers() {
403
+ const result = await this.db.users.orderBy({ createdAt: 'asc' }).many();
404
+ return result.data ?? [];
405
+ }
406
+
407
+ export async function createOrgUser(formData: FormData) {
408
+ const user = await getCurrentUser();
409
+ if (!user?.orgId) throw new Error('Not authenticated');
410
+
411
+ const email = String(formData.get('email') ?? '').trim().toLowerCase();
412
+ if (!email) throw new Error('Email is required');
413
+
414
+ const passwordHash = await hashPassword(await randomPassword(), undefined, this.env.AUTH_SECRET);
415
+ await this.db.users.insert({ email, role: 'member', passwordHash });
416
+ redirect('/settings/users');
417
+ }
260
418
  ```
261
419
 
262
- ## Durable Objects
420
+ Optional lifecycle exports in function mode:
263
421
 
264
- Extend `kuratchiDO` to create a Durable Object class backed by SQLite. The `static binding` name must match the binding in `wrangler.jsonc`.
422
+ - `export async function onInit()`
423
+ - `export async function onAlarm(...args)`
424
+ - `export function onMessage(...args)`
425
+
426
+ These lifecycle names are not exposed as RPC methods.
427
+
428
+ ### Class mode (optional)
429
+
430
+ Class-based handlers are still supported in `.do.ts` files:
265
431
 
266
432
  ```ts
267
- // src/server/notes.do.ts
268
433
  import { kuratchiDO } from '@kuratchi/js';
269
- import type { Note } from '../schemas/notes';
270
434
 
271
435
  export default class NotesDO extends kuratchiDO {
272
436
  static binding = 'NOTES_DO';
273
437
 
274
- async getNotes(): Promise<Note[]> {
438
+ async getNotes() {
275
439
  return (await this.db.notes.orderBy({ created_at: 'desc' }).many()).data ?? [];
276
440
  }
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
441
  }
286
442
  ```
287
443
 
288
- Declare it in `kuratchi.config.ts` and in `wrangler.jsonc`. The compiler exports it from `.kuratchi/worker.js` automatically.
444
+ Declare it in `kuratchi.config.ts` and in `wrangler.jsonc`. The compiler exports DO classes from `.kuratchi/worker.js` automatically.
289
445
 
290
446
  ```jsonc
291
447
  // wrangler.jsonc
@@ -298,22 +454,6 @@ Declare it in `kuratchi.config.ts` and in `wrangler.jsonc`. The compiler exports
298
454
  ]
299
455
  }
300
456
  ```
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
457
  ## Runtime APIs
318
458
 
319
459
  These are available anywhere in server-side route code:
@@ -330,7 +470,26 @@ import {
330
470
  } from '@kuratchi/js';
331
471
  ```
332
472
 
333
- Environment bindings are accessed directly via the Cloudflare Workers API — no framework wrapper needed:
473
+ ## Environment bindings
474
+
475
+ Cloudflare env is server-only.
476
+
477
+ - Route top-level `<script>`, route `load()` functions, server actions, API handlers, and other server modules can read env.
478
+ - Templates, components, and client `<script>` blocks cannot read env directly.
479
+ - If a value must reach the browser, compute it in the server route script and reference it in the template, or return it from `load()` explicitly.
480
+
481
+ ```html
482
+ <script>
483
+ import { env } from 'cloudflare:workers';
484
+ const turnstileSiteKey = env.TURNSTILE_SITE_KEY || '';
485
+ </script>
486
+
487
+ if (turnstileSiteKey) {
488
+ <div class="cf-turnstile" data-sitekey={turnstileSiteKey}></div>
489
+ }
490
+ ```
491
+
492
+ Server modules can still access env directly:
334
493
 
335
494
  ```ts
336
495
  import { env } from 'cloudflare:workers';
@@ -357,7 +516,10 @@ export default defineConfig({
357
516
  },
358
517
  }),
359
518
  durableObjects: {
360
- NOTES_DO: { className: 'NotesDO' },
519
+ NOTES_DO: {
520
+ className: 'NotesDO',
521
+ files: ['notes.do.ts'],
522
+ },
361
523
  },
362
524
  auth: kuratchiAuthConfig({
363
525
  cookieName: 'kuratchi_session',
@@ -375,6 +537,20 @@ npx kuratchi build # one-shot build
375
537
  npx kuratchi watch # watch mode (for use with wrangler dev)
376
538
  ```
377
539
 
540
+ ## Testing the Framework
541
+
542
+ Run framework tests from `packages/kuratchi-js`:
543
+
544
+ ```bash
545
+ bun run test
546
+ ```
547
+
548
+ Watch mode:
549
+
550
+ ```bash
551
+ bun run test:watch
552
+ ```
553
+
378
554
  ## TypeScript & Worker types
379
555
 
380
556
  ```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).