@kuratchi/js 0.0.19 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +193 -7
  2. package/dist/cli.js +8 -0
  3. package/dist/compiler/client-module-pipeline.d.ts +8 -0
  4. package/dist/compiler/client-module-pipeline.js +181 -30
  5. package/dist/compiler/compiler-shared.d.ts +23 -0
  6. package/dist/compiler/component-pipeline.js +9 -1
  7. package/dist/compiler/config-reading.js +27 -1
  8. package/dist/compiler/convention-discovery.d.ts +2 -0
  9. package/dist/compiler/convention-discovery.js +16 -0
  10. package/dist/compiler/durable-object-pipeline.d.ts +1 -0
  11. package/dist/compiler/durable-object-pipeline.js +459 -119
  12. package/dist/compiler/index.js +40 -1
  13. package/dist/compiler/page-route-pipeline.js +31 -2
  14. package/dist/compiler/parser.d.ts +1 -0
  15. package/dist/compiler/parser.js +47 -4
  16. package/dist/compiler/root-layout-pipeline.js +18 -3
  17. package/dist/compiler/route-pipeline.d.ts +2 -0
  18. package/dist/compiler/route-pipeline.js +19 -3
  19. package/dist/compiler/routes-module-feature-blocks.js +143 -17
  20. package/dist/compiler/routes-module-types.d.ts +1 -0
  21. package/dist/compiler/server-module-pipeline.js +24 -0
  22. package/dist/compiler/template.d.ts +4 -0
  23. package/dist/compiler/template.js +50 -18
  24. package/dist/compiler/type-generator.d.ts +8 -0
  25. package/dist/compiler/type-generator.js +124 -0
  26. package/dist/compiler/worker-output-pipeline.js +2 -0
  27. package/dist/compiler/wrangler-sync.d.ts +3 -0
  28. package/dist/compiler/wrangler-sync.js +25 -11
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.js +1 -0
  31. package/dist/runtime/context.d.ts +9 -1
  32. package/dist/runtime/context.js +25 -2
  33. package/dist/runtime/generated-worker.d.ts +1 -0
  34. package/dist/runtime/generated-worker.js +11 -7
  35. package/dist/runtime/index.d.ts +2 -0
  36. package/dist/runtime/index.js +1 -0
  37. package/dist/runtime/navigation.d.ts +8 -0
  38. package/dist/runtime/navigation.js +8 -0
  39. package/dist/runtime/request.d.ts +28 -0
  40. package/dist/runtime/request.js +44 -0
  41. package/dist/runtime/schema.d.ts +49 -0
  42. package/dist/runtime/schema.js +148 -0
  43. package/dist/runtime/types.d.ts +2 -0
  44. package/dist/runtime/validation.d.ts +26 -0
  45. package/dist/runtime/validation.js +147 -0
  46. package/package.json +76 -68
@@ -152,20 +152,24 @@ function buildAppendStatement(expression, emitCall) {
152
152
  // Otherwise, use array push for O(n) performance
153
153
  return emitCall ? `${emitCall}(${expression});` : `__parts.push(${expression});`;
154
154
  }
155
- function extractPollFragmentExpr(tagText, rpcNameMap) {
156
- const pollMatch = tagText.match(/\bdata-poll=\{([\s\S]*?)\}/);
157
- if (!pollMatch)
158
- return null;
159
- const inner = pollMatch[1].trim();
155
+ function buildPollFragmentExpr(inner, rpcNameMap, pollIndex = 0) {
160
156
  const callMatch = inner.match(/^([A-Za-z_$][\w$]*)\(([\s\S]*)\)$/);
161
157
  if (!callMatch)
162
158
  return null;
163
159
  const fnName = callMatch[1];
164
160
  const rpcName = rpcNameMap?.get(fnName) || fnName;
165
161
  const argsExpr = (callMatch[2] || '').trim();
162
+ const fragmentPrefix = `__poll_${pollIndex}`;
166
163
  if (!argsExpr)
167
- return JSON.stringify(`__poll_${rpcName}`);
168
- return `'__poll_' + String(${argsExpr}).replace(/[^a-zA-Z0-9]/g, '_')`;
164
+ return JSON.stringify(`${fragmentPrefix}_${rpcName}`);
165
+ return `${JSON.stringify(fragmentPrefix + '_')} + String(${argsExpr}).replace(/[^a-zA-Z0-9]/g, '_')`;
166
+ }
167
+ function extractPollFragmentExpr(tagText, rpcNameMap, pollIndex = 0) {
168
+ const pollMatch = tagText.match(/\bdata-poll=\{([\s\S]*?)\}/);
169
+ if (!pollMatch)
170
+ return null;
171
+ const inner = pollMatch[1].trim();
172
+ return buildPollFragmentExpr(inner, rpcNameMap, pollIndex);
169
173
  }
170
174
  function findTagEnd(template, start) {
171
175
  let quote = null;
@@ -208,6 +212,7 @@ function instrumentPollFragments(template, rpcNameMap) {
208
212
  const out = [];
209
213
  const stack = [];
210
214
  let cursor = 0;
215
+ let pollIndex = 0;
211
216
  while (cursor < template.length) {
212
217
  const lt = template.indexOf('<', cursor);
213
218
  if (lt === -1) {
@@ -245,15 +250,20 @@ function instrumentPollFragments(template, rpcNameMap) {
245
250
  continue;
246
251
  }
247
252
  const openMatch = tagText.match(/^<\s*([A-Za-z][\w:-]*)\b/);
248
- out.push(tagText);
249
253
  if (!openMatch) {
254
+ out.push(tagText);
250
255
  cursor = tagEnd + 1;
251
256
  continue;
252
257
  }
253
258
  const tagName = openMatch[1].toLowerCase();
254
259
  const isVoidLike = /\/\s*>$/.test(tagText) || /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/i.test(tagName);
255
- const fragmentExpr = extractPollFragmentExpr(tagText, rpcNameMap);
260
+ const fragmentExpr = extractPollFragmentExpr(tagText, rpcNameMap, pollIndex);
261
+ const instrumentedTagText = fragmentExpr
262
+ ? tagText.replace(/\bdata-poll=\{([\s\S]*?)\}/, (match) => `${match} data-kuratchi-poll-fragment={${fragmentExpr}}`)
263
+ : tagText;
264
+ out.push(instrumentedTagText);
256
265
  if (fragmentExpr) {
266
+ pollIndex += 1;
257
267
  out.push(`${FRAGMENT_OPEN_MARKER}${encodeURIComponent(fragmentExpr)}-->`);
258
268
  if (isVoidLike) {
259
269
  out.push(FRAGMENT_CLOSE_MARKER);
@@ -404,6 +414,7 @@ export function compileTemplate(template, componentNames, actionNames, rpcNameMa
404
414
  const childTemplate = childLines.join('\n');
405
415
  const childBody = compileTemplate(childTemplate, componentNames, actionNames, rpcNameMap, {
406
416
  clientRouteRegistry: options.clientRouteRegistry,
417
+ awaitQueryBindings: options.awaitQueryBindings,
407
418
  });
408
419
  // Wrap in an IIFE that returns the children HTML
409
420
  const childrenExpr = `(function() { ${childBody}; return __html; })()`;
@@ -604,6 +615,14 @@ function transformClientScriptBlock(block) {
604
615
  // TypeScript is preserved — wrangler's esbuild handles transpilation
605
616
  return `${openTag}${out.join('\n')}${closeTag}`;
606
617
  }
618
+ function extractAwaitTemplateCall(expr) {
619
+ const match = expr.trim().match(/^await\s+([A-Za-z_$][\w$]*)\(([\s\S]*)\)$/);
620
+ if (!match)
621
+ return null;
622
+ const fnName = match[1];
623
+ const argsExpr = (match[2] || '').trim();
624
+ return { fnName, argsExpr, awaitExpr: `${fnName}(${argsExpr})` };
625
+ }
607
626
  function braceDelta(line) {
608
627
  let delta = 0;
609
628
  let inSingle = false;
@@ -843,6 +862,7 @@ function tryCompileComponentTag(line, componentNames, actionNames, rpcNameMap, o
843
862
  // Compile the inline content as a mini-template to handle {expr} interpolation
844
863
  const childBody = compileTemplate(innerContent, componentNames, actionNames, rpcNameMap, {
845
864
  clientRouteRegistry: options.clientRouteRegistry,
865
+ awaitQueryBindings: options.awaitQueryBindings,
846
866
  });
847
867
  const childrenExpr = `(function() { ${childBody}; return __html; })()`;
848
868
  return buildAppendStatement(`${funcName}({ ${propsStr}${propsStr ? ', ' : ''}children: ${childrenExpr} }, __esc)`, options.emitCall);
@@ -1028,8 +1048,14 @@ function compileHtmlSegment(line, actionNames, rpcNameMap, options = {}) {
1028
1048
  pos = closeIdx + 1;
1029
1049
  continue;
1030
1050
  }
1051
+ else if (attrName === 'data-kuratchi-poll-fragment') {
1052
+ result = result.replace(/\s*data-kuratchi-poll-fragment=$/, '');
1053
+ result += ` data-poll-id="\${__signFragment(${inner})}"`;
1054
+ pos = closeIdx + 1;
1055
+ continue;
1056
+ }
1031
1057
  else if (attrName === 'data-poll') {
1032
- // data-poll={fn(args)} → data-poll="fnName" data-poll-args="[serialized]" data-poll-id="signed-id"
1058
+ // data-poll={fn(args)} → data-poll="fnName" data-poll-args="[serialized]"
1033
1059
  const pollCallMatch = inner.match(/^(\w+)\((.*)\)$/);
1034
1060
  if (pollCallMatch) {
1035
1061
  const fnName = pollCallMatch[1];
@@ -1037,16 +1063,11 @@ function compileHtmlSegment(line, actionNames, rpcNameMap, options = {}) {
1037
1063
  const argsExpr = pollCallMatch[2].trim();
1038
1064
  // Remove the trailing "data-poll=" we already appended
1039
1065
  result = result.replace(/\s*data-poll=$/, '');
1040
- // Emit data-poll, data-poll-args, and signed data-poll-id (signed at runtime for security)
1066
+ // Emit data-poll and data-poll-args. The signed poll fragment ID is emitted
1067
+ // from the synthetic data-kuratchi-poll-fragment attribute injected earlier.
1041
1068
  result += ` data-poll="${rpcName}"`;
1042
1069
  if (argsExpr) {
1043
1070
  result += ` data-poll-args="\${__esc(JSON.stringify([${argsExpr}]))}"`;
1044
- // Sign the fragment ID at runtime with __signFragment(fragmentId, routePath)
1045
- result += ` data-poll-id="\${__signFragment('__poll_' + String(${argsExpr}).replace(/[^a-zA-Z0-9]/g, '_'))}"`;
1046
- }
1047
- else {
1048
- // No args - sign with function name as base ID
1049
- result += ` data-poll-id="\${__signFragment('__poll_${rpcName}')}"`;
1050
1071
  }
1051
1072
  }
1052
1073
  pos = closeIdx + 1;
@@ -1119,7 +1140,18 @@ function compileHtmlSegment(line, actionNames, rpcNameMap, options = {}) {
1119
1140
  }
1120
1141
  }
1121
1142
  else {
1122
- result += `\${__esc(${inner})}`;
1143
+ const awaitCall = extractAwaitTemplateCall(inner);
1144
+ const awaitBinding = awaitCall ? options.awaitQueryBindings?.get(awaitCall.awaitExpr) : null;
1145
+ if (awaitCall && awaitBinding) {
1146
+ result += `<span data-remote-read="${awaitBinding.rpcId}" data-get="${awaitBinding.rpcId}"`;
1147
+ if (awaitCall.argsExpr) {
1148
+ result += ` data-get-args="\${__esc(JSON.stringify([${awaitCall.argsExpr}]))}"`;
1149
+ }
1150
+ result += `>\${__esc(((${awaitBinding.asName} && ${awaitBinding.asName}.data) ?? ''))}</span>`;
1151
+ }
1152
+ else {
1153
+ result += `\${__esc(${inner})}`;
1154
+ }
1123
1155
  }
1124
1156
  }
1125
1157
  pos = closeIdx + 1;
@@ -0,0 +1,8 @@
1
+ export interface GenerateTypesOptions {
2
+ projectDir: string;
3
+ schemaPath?: string;
4
+ outputPath?: string;
5
+ localsInterface?: string;
6
+ }
7
+ export declare function generateAppTypes(options: GenerateTypesOptions): string;
8
+ export declare function writeAppTypes(options: GenerateTypesOptions): void;
@@ -0,0 +1,124 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ function sqliteTypeToTs(sqlType) {
4
+ const lower = sqlType.toLowerCase();
5
+ if (lower.includes('integer') || lower.includes('int') || lower.includes('real') || lower.includes('numeric')) {
6
+ return 'number';
7
+ }
8
+ if (lower.includes('text') || lower.includes('varchar') || lower.includes('char')) {
9
+ return 'string';
10
+ }
11
+ if (lower.includes('blob')) {
12
+ return 'Uint8Array';
13
+ }
14
+ if (lower.includes('json')) {
15
+ return 'Record<string, unknown>';
16
+ }
17
+ if (lower.includes('boolean') || lower.includes('bool')) {
18
+ return 'boolean';
19
+ }
20
+ return 'unknown';
21
+ }
22
+ function parseSchemaColumn(name, definition) {
23
+ const lower = definition.toLowerCase();
24
+ const nullable = !lower.includes('not null');
25
+ const hasDefault = lower.includes('default');
26
+ const type = sqliteTypeToTs(definition);
27
+ return { name, type, nullable, hasDefault };
28
+ }
29
+ function parseSchemaFromSource(source) {
30
+ const tables = [];
31
+ // Match tables: { tableName: { col: 'def', ... }, ... }
32
+ const tablesMatch = source.match(/tables\s*:\s*\{([\s\S]*?)\n\t?\}/);
33
+ if (!tablesMatch)
34
+ return tables;
35
+ const tablesBlock = tablesMatch[1];
36
+ // Match each table definition
37
+ const tableRegex = /(\w+)\s*:\s*\{([^}]+)\}/g;
38
+ let match;
39
+ while ((match = tableRegex.exec(tablesBlock)) !== null) {
40
+ const tableName = match[1];
41
+ const columnsBlock = match[2];
42
+ const columns = [];
43
+ // Match each column: name: 'definition'
44
+ const colRegex = /(\w+)\s*:\s*['"]([^'"]+)['"]/g;
45
+ let colMatch;
46
+ while ((colMatch = colRegex.exec(columnsBlock)) !== null) {
47
+ columns.push(parseSchemaColumn(colMatch[1], colMatch[2]));
48
+ }
49
+ tables.push({ name: tableName, columns });
50
+ }
51
+ return tables;
52
+ }
53
+ function generateTableTypes(tables) {
54
+ const lines = [];
55
+ for (const table of tables) {
56
+ const pascalName = table.name
57
+ .split('_')
58
+ .map(s => s.charAt(0).toUpperCase() + s.slice(1))
59
+ .join('');
60
+ lines.push(` /** Row type for ${table.name} table */`);
61
+ lines.push(` interface ${pascalName}Row {`);
62
+ for (const col of table.columns) {
63
+ const optional = col.nullable || col.hasDefault ? '?' : '';
64
+ lines.push(` ${col.name}${optional}: ${col.type};`);
65
+ }
66
+ lines.push(` }`);
67
+ lines.push('');
68
+ }
69
+ return lines.join('\n');
70
+ }
71
+ export function generateAppTypes(options) {
72
+ const { projectDir, schemaPath = 'src/server/schema.ts', outputPath = 'src/app.d.ts', localsInterface, } = options;
73
+ const schemaFullPath = path.join(projectDir, schemaPath);
74
+ let tables = [];
75
+ if (fs.existsSync(schemaFullPath)) {
76
+ const schemaSource = fs.readFileSync(schemaFullPath, 'utf-8');
77
+ tables = parseSchemaFromSource(schemaSource);
78
+ }
79
+ const tableTypes = tables.length > 0 ? generateTableTypes(tables) : '';
80
+ // Check if user has existing Locals definition to preserve
81
+ const outputFullPath = path.join(projectDir, outputPath);
82
+ let existingLocals = null;
83
+ if (fs.existsSync(outputFullPath)) {
84
+ const existing = fs.readFileSync(outputFullPath, 'utf-8');
85
+ // Extract user-defined Locals interface (between USER LOCALS START/END markers)
86
+ const localsMatch = existing.match(/\/\/ USER LOCALS START\n([\s\S]*?)\/\/ USER LOCALS END/);
87
+ if (localsMatch) {
88
+ existingLocals = localsMatch[1];
89
+ }
90
+ }
91
+ const localsBlock = existingLocals || localsInterface || ` interface Locals {
92
+ userId: number;
93
+ userEmail: string;
94
+ }`;
95
+ const output = `/**
96
+ * Type declarations for kuratchi app.
97
+ *
98
+ * DB types are auto-generated from schema.ts - regenerate with: kuratchi types
99
+ * Edit the Locals interface below to match your runtime.hook.ts
100
+ */
101
+ declare global {
102
+ namespace App {
103
+ /** Request-scoped locals set by runtime hooks */
104
+ // USER LOCALS START
105
+ ${localsBlock}
106
+ // USER LOCALS END
107
+
108
+ ${tableTypes ? ` // Database table row types (auto-generated from schema.ts)\n${tableTypes}` : ''} }
109
+ }
110
+
111
+ export {};
112
+ `;
113
+ return output;
114
+ }
115
+ export function writeAppTypes(options) {
116
+ const output = generateAppTypes(options);
117
+ const outputPath = path.join(options.projectDir, options.outputPath || 'src/app.d.ts');
118
+ const dir = path.dirname(outputPath);
119
+ if (!fs.existsSync(dir)) {
120
+ fs.mkdirSync(dir, { recursive: true });
121
+ }
122
+ fs.writeFileSync(outputPath, output, 'utf-8');
123
+ console.log(`[kuratchi] Generated types → ${path.relative(options.projectDir, outputPath)}`);
124
+ }
@@ -19,7 +19,9 @@ export function toWorkerImportPath(projectDir, outDir, filePath) {
19
19
  return rel.replace(/\.(ts|js|mjs|cjs)$/, '');
20
20
  }
21
21
  export function buildWorkerEntrypointSource(opts) {
22
+ const doClassSet = new Set(opts.doClassNames);
22
23
  const workerClassExports = opts.workerClassEntries
24
+ .filter((entry) => !doClassSet.has(entry.className))
23
25
  .map((entry) => {
24
26
  const importPath = toWorkerImportPath(opts.projectDir, opts.outDir, entry.file);
25
27
  if (entry.exportKind === 'default') {
@@ -6,6 +6,9 @@ export interface WranglerSyncConfig {
6
6
  workflows: WranglerSyncEntry[];
7
7
  containers: WranglerSyncEntry[];
8
8
  durableObjects: WranglerSyncEntry[];
9
+ /** Relative path to the public assets directory (e.g. '.kuratchi/public/'). When set, the
10
+ * `assets.directory` and `assets.binding` keys in wrangler.jsonc are kept in sync automatically. */
11
+ assetsDirectory?: string;
9
12
  }
10
13
  export declare function syncWranglerConfig(opts: {
11
14
  projectDir: string;
@@ -128,24 +128,23 @@ export function syncWranglerConfig(opts) {
128
128
  }
129
129
  if (opts.config.containers.length > 0) {
130
130
  const existingContainers = wranglerConfig.containers || [];
131
- const existingByBinding = new Map(existingContainers.map((container) => [container.binding, container]));
131
+ const existingByClassName = new Map(existingContainers.map((container) => [container.class_name, container]));
132
132
  for (const container of opts.config.containers) {
133
133
  const name = container.binding.toLowerCase().replace(/_/g, '-');
134
134
  const entry = {
135
135
  name,
136
- binding: container.binding,
137
136
  class_name: container.className,
138
137
  };
139
- const existing = existingByBinding.get(container.binding);
138
+ const existing = existingByClassName.get(container.className);
140
139
  if (!existing) {
141
140
  existingContainers.push(entry);
142
141
  changed = true;
143
- console.log(`[kuratchi] Added container "${container.binding}" to wrangler config`);
142
+ console.log(`[kuratchi] Added container "${container.className}" to wrangler config`);
144
143
  }
145
- else if (existing.class_name !== container.className) {
146
- existing.class_name = container.className;
144
+ else if (existing.name !== name) {
145
+ existing.name = name;
147
146
  changed = true;
148
- console.log(`[kuratchi] Updated container "${container.binding}" class_name to "${container.className}"`);
147
+ console.log(`[kuratchi] Updated container "${container.className}" name to "${name}"`);
149
148
  }
150
149
  }
151
150
  wranglerConfig.containers = existingContainers;
@@ -170,13 +169,28 @@ export function syncWranglerConfig(opts) {
170
169
  changed = true;
171
170
  console.log(`[kuratchi] Added durable_object "${durableObject.binding}" to wrangler config`);
172
171
  }
173
- else if (existing.class_name !== durableObject.className) {
174
- existing.class_name = durableObject.className;
172
+ }
173
+ wranglerConfig.durable_objects.bindings = existingBindings;
174
+ }
175
+ if (opts.config.assetsDirectory !== undefined) {
176
+ const existing = wranglerConfig.assets;
177
+ if (!existing) {
178
+ wranglerConfig.assets = { directory: opts.config.assetsDirectory, binding: 'ASSETS' };
179
+ changed = true;
180
+ console.log(`[kuratchi] Added static assets directory "${opts.config.assetsDirectory}" to wrangler config`);
181
+ }
182
+ else {
183
+ if (existing.directory !== opts.config.assetsDirectory) {
184
+ existing.directory = opts.config.assetsDirectory;
185
+ changed = true;
186
+ console.log(`[kuratchi] Updated static assets directory to "${opts.config.assetsDirectory}" in wrangler config`);
187
+ }
188
+ if (!existing.binding) {
189
+ existing.binding = 'ASSETS';
175
190
  changed = true;
176
- console.log(`[kuratchi] Updated durable_object "${durableObject.binding}" class_name to "${durableObject.className}"`);
191
+ console.log(`[kuratchi] Added ASSETS binding to static assets config in wrangler config`);
177
192
  }
178
193
  }
179
- wranglerConfig.durable_objects.bindings = existingBindings;
180
194
  }
181
195
  if (!changed)
182
196
  return;
package/dist/index.d.ts CHANGED
@@ -8,9 +8,11 @@ export { defineConfig } from './runtime/config.js';
8
8
  export { defineRuntime } from './runtime/runtime.js';
9
9
  export { getCtx, getEnv, getRequest, getLocals, getParams, getParam, RedirectError, redirect, goto, setBreadcrumbs, getBreadcrumbs, breadcrumbsHome, breadcrumbsPrev, breadcrumbsNext, breadcrumbsCurrent, buildDefaultBreadcrumbs, } from './runtime/context.js';
10
10
  export { kuratchiDO, doRpc, getDb } from './runtime/do.js';
11
+ export { SchemaValidationError, schema, } from './runtime/schema.js';
11
12
  export { ActionError } from './runtime/action.js';
12
13
  export { PageError } from './runtime/page-error.js';
13
14
  export { extractSubdomainSlug, extractSlugFromPrefix, matchContainerViewPath, rewriteProxyLocationHeader, buildContainerRequest, createContainerEnvVars, startContainer, proxyToContainer, handleContainerRouting, forwardJsonPostToContainerDO, matchSiteViewPath, buildSiteContainerRequest, createWpContainerEnvVars, startSiteContainer, proxyToSiteContainer, } from './runtime/containers.js';
14
15
  export type { AppConfig, kuratchiConfig, DatabaseConfig, AuthConfig, RouteContext, RouteModule, RuntimeContext, RuntimeDefinition, RuntimeStep, RuntimeNext, RuntimeErrorResult, } from './runtime/types.js';
15
16
  export type { RpcOf } from './runtime/do.js';
17
+ export type { SchemaType, InferSchema } from './runtime/schema.js';
16
18
  export { url, pathname, searchParams, headers, method, params, slug } from './runtime/request.js';
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export { defineConfig } from './runtime/config.js';
9
9
  export { defineRuntime } from './runtime/runtime.js';
10
10
  export { getCtx, getEnv, getRequest, getLocals, getParams, getParam, RedirectError, redirect, goto, setBreadcrumbs, getBreadcrumbs, breadcrumbsHome, breadcrumbsPrev, breadcrumbsNext, breadcrumbsCurrent, buildDefaultBreadcrumbs, } from './runtime/context.js';
11
11
  export { kuratchiDO, doRpc, getDb } from './runtime/do.js';
12
+ export { SchemaValidationError, schema, } from './runtime/schema.js';
12
13
  export { ActionError } from './runtime/action.js';
13
14
  export { PageError } from './runtime/page-error.js';
14
15
  export { extractSubdomainSlug, extractSlugFromPrefix, matchContainerViewPath, rewriteProxyLocationHeader, buildContainerRequest, createContainerEnvVars, startContainer, proxyToContainer, handleContainerRouting, forwardJsonPostToContainerDO,
@@ -20,6 +20,12 @@ export declare class RedirectError extends Error {
20
20
  }
21
21
  /** Called by the framework at the start of each request */
22
22
  export declare function __setRequestContext(ctx: any, request: Request, env?: Record<string, any>): void;
23
+ /**
24
+ * Push a new request context for the duration of a DO RPC call.
25
+ * Saves current state and returns a restore function.
26
+ * @internal
27
+ */
28
+ export declare function __pushRequestContext(rpcContext: any, ctx: any, env: any): () => void;
23
29
  /** Get the execution context (Worker: ExecutionContext, DO: DurableObjectState) */
24
30
  export declare function getCtx(): any;
25
31
  /** Get the current environment bindings */
@@ -37,7 +43,9 @@ export declare function getParam(name: string): string | undefined;
37
43
  * Throws a redirect signal consumed by the framework's PRG flow.
38
44
  */
39
45
  export declare function redirect(path: string, status?: number): never;
40
- /** Backward-compatible alias for redirect() */
46
+ /**
47
+ * @deprecated Use redirect() instead. This alias will be removed in a future version.
48
+ */
41
49
  export declare function goto(path: string, status?: number): never;
42
50
  export declare function setBreadcrumbs(items: BreadcrumbItem[]): void;
43
51
  export declare function getBreadcrumbs(): BreadcrumbItem[];
@@ -32,12 +32,33 @@ export function __setRequestContext(ctx, request, env) {
32
32
  __locals = {};
33
33
  __setRequestState(request);
34
34
  // Expose context on globalThis for @kuratchi/auth and other packages
35
- // Workers are single-threaded per request — this is safe
35
+ // Workers are single-threaded per request this is safe
36
36
  globalThis.__kuratchi_context__ = {
37
37
  get request() { return __request; },
38
38
  get locals() { return __locals; },
39
39
  };
40
40
  }
41
+ /**
42
+ * Push a new request context for the duration of a DO RPC call.
43
+ * Saves current state and returns a restore function.
44
+ * @internal
45
+ */
46
+ export function __pushRequestContext(rpcContext, ctx, env) {
47
+ const prevCtx = __ctx;
48
+ const prevRequest = __request;
49
+ const prevEnv = __env;
50
+ const prevLocals = __locals;
51
+ __ctx = ctx;
52
+ __request = rpcContext?.request ?? __request;
53
+ __env = env ?? __env;
54
+ __locals = rpcContext?.locals ? { ...rpcContext.locals } : {};
55
+ return () => {
56
+ __ctx = prevCtx;
57
+ __request = prevRequest;
58
+ __env = prevEnv;
59
+ __locals = prevLocals;
60
+ };
61
+ }
41
62
  /** Get the execution context (Worker: ExecutionContext, DO: DurableObjectState) */
42
63
  export function getCtx() {
43
64
  const doSelf = __getDoSelf();
@@ -83,7 +104,9 @@ export function redirect(path, status = 303) {
83
104
  __locals.__redirectStatus = status;
84
105
  throw new RedirectError(path, status);
85
106
  }
86
- /** Backward-compatible alias for redirect() */
107
+ /**
108
+ * @deprecated Use redirect() instead. This alias will be removed in a future version.
109
+ */
87
110
  export function goto(path, status = 303) {
88
111
  redirect(path, status);
89
112
  }
@@ -14,6 +14,7 @@ export interface GeneratedPageRoute {
14
14
  load?: (params: Record<string, string>) => Promise<unknown> | unknown;
15
15
  actions?: Record<string, (...args: any[]) => Promise<unknown> | unknown>;
16
16
  rpc?: Record<string, (...args: any[]) => Promise<unknown> | unknown>;
17
+ rpcSchemas?: Record<string, any>;
17
18
  /** Allowed query function names for this route (for query override validation) */
18
19
  allowedQueries?: string[];
19
20
  render: (data: Record<string, any>) => PageRenderOutput;
@@ -1,6 +1,7 @@
1
1
  import { __esc, __getLocals, __setLocal, __setRequestContext, buildDefaultBreadcrumbs } from './context.js';
2
2
  import { Router } from './router.js';
3
3
  import { initCsrf, getCsrfCookieHeader, validateRpcRequest, validateActionRequest, validateSignedFragment, validateQueryOverride, parseQueryArgs, CSRF_DEFAULTS, } from './security.js';
4
+ import { SchemaValidationError, parseRpcArgsPayload, validateSchemaInput, } from './schema.js';
4
5
  export function createGeneratedWorker(opts) {
5
6
  const router = new Router();
6
7
  const runtimeEntries = __getRuntimeEntries(opts.runtimeDefinition);
@@ -225,19 +226,22 @@ async function __handleRpc(route, workflowStatusRpc, runtimeCtx, securityConfig)
225
226
  }));
226
227
  }
227
228
  try {
228
- const rpcArgsStr = url.searchParams.get('_args');
229
- let rpcArgs = [];
230
- if (rpcArgsStr) {
231
- const parsed = JSON.parse(rpcArgsStr);
232
- rpcArgs = Array.isArray(parsed) ? parsed : [];
233
- }
229
+ const rpcArgs = parseRpcArgsPayload(url.searchParams.get('_args'));
234
230
  const rpcFn = hasRouteRpc ? route.rpc[rpcName] : workflowStatusRpc[rpcName];
235
- const rpcResult = await rpcFn(...rpcArgs);
231
+ const rpcSchema = hasRouteRpc ? route.rpcSchemas?.[rpcName] : undefined;
232
+ const validatedArgs = hasRouteRpc ? validateSchemaInput(rpcSchema, rpcArgs) : rpcArgs;
233
+ const rpcResult = await rpcFn(...validatedArgs);
236
234
  return __attachCookies(new Response(JSON.stringify({ ok: true, data: rpcResult }), {
237
235
  headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
238
236
  }));
239
237
  }
240
238
  catch (err) {
239
+ if (err instanceof SchemaValidationError || err?.isSchemaValidationError) {
240
+ return __secHeaders(new Response(JSON.stringify({ ok: false, error: err.message }), {
241
+ status: 400,
242
+ headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
243
+ }));
244
+ }
241
245
  console.error('[kuratchi] RPC error:', err);
242
246
  const errMsg = __sanitizeErrorMessage(err);
243
247
  return __secHeaders(new Response(JSON.stringify({ ok: false, error: errMsg }), {
@@ -5,6 +5,8 @@ export { defineRuntime } from './runtime.js';
5
5
  export { Router, filePathToPattern } from './router.js';
6
6
  export { getCtx, getRequest, getLocals, getParams, getParam, RedirectError, redirect, goto, setBreadcrumbs, getBreadcrumbs, breadcrumbsHome, breadcrumbsPrev, breadcrumbsNext, breadcrumbsCurrent, buildDefaultBreadcrumbs, } from './context.js';
7
7
  export { kuratchiDO, doRpc } from './do.js';
8
+ export { SchemaValidationError, schema, validateSchemaInput, parseRpcArgsPayload, } from './schema.js';
9
+ export type { SchemaType, InferSchema, } from './schema.js';
8
10
  export { initCsrf, getCsrfToken, validateCsrf, getCsrfCookieHeader, validateRpcRequest, validateActionRequest, applySecurityHeaders, signFragmentId, validateSignedFragment, validateQueryOverride, parseQueryArgs, CSRF_DEFAULTS, } from './security.js';
9
11
  export type { RpcSecurityConfig, ActionSecurityConfig, SecurityHeadersConfig, } from './security.js';
10
12
  export { extractSubdomainSlug, extractSlugFromPrefix, matchContainerViewPath, rewriteProxyLocationHeader, buildContainerRequest, createContainerEnvVars, startContainer, proxyToContainer, handleContainerRouting, forwardJsonPostToContainerDO, matchSiteViewPath, buildSiteContainerRequest, createWpContainerEnvVars, startSiteContainer, proxyToSiteContainer, } from './containers.js';
@@ -5,6 +5,7 @@ export { defineRuntime } from './runtime.js';
5
5
  export { Router, filePathToPattern } from './router.js';
6
6
  export { getCtx, getRequest, getLocals, getParams, getParam, RedirectError, redirect, goto, setBreadcrumbs, getBreadcrumbs, breadcrumbsHome, breadcrumbsPrev, breadcrumbsNext, breadcrumbsCurrent, buildDefaultBreadcrumbs, } from './context.js';
7
7
  export { kuratchiDO, doRpc } from './do.js';
8
+ export { SchemaValidationError, schema, validateSchemaInput, parseRpcArgsPayload, } from './schema.js';
8
9
  export { initCsrf, getCsrfToken, validateCsrf, getCsrfCookieHeader, validateRpcRequest, validateActionRequest, applySecurityHeaders, signFragmentId, validateSignedFragment, validateQueryOverride, parseQueryArgs, CSRF_DEFAULTS, } from './security.js';
9
10
  export { extractSubdomainSlug, extractSlugFromPrefix, matchContainerViewPath, rewriteProxyLocationHeader, buildContainerRequest, createContainerEnvVars, startContainer, proxyToContainer, handleContainerRouting, forwardJsonPostToContainerDO,
10
11
  // Compatibility aliases
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Navigation helpers for kuratchi routes.
3
+ * Import via: import { redirect } from 'kuratchi:navigation';
4
+ *
5
+ * redirect() is server-side only — works in route scripts, server modules, and form actions.
6
+ * It throws a RedirectError that the framework catches and converts to a 303 redirect response.
7
+ */
8
+ export { redirect } from './context.js';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Navigation helpers for kuratchi routes.
3
+ * Import via: import { redirect } from 'kuratchi:navigation';
4
+ *
5
+ * redirect() is server-side only — works in route scripts, server modules, and form actions.
6
+ * It throws a RedirectError that the framework catches and converts to a 303 redirect response.
7
+ */
8
+ export { redirect } from './context.js';
@@ -1,3 +1,11 @@
1
+ declare global {
2
+ namespace App {
3
+ /** Request-scoped locals set by runtime hooks. Extend in your app.d.ts */
4
+ interface Locals {
5
+ [key: string]: unknown;
6
+ }
7
+ }
8
+ }
1
9
  export declare let url: URL;
2
10
  export declare let pathname: string;
3
11
  export declare let searchParams: URLSearchParams;
@@ -5,5 +13,25 @@ export declare let headers: Headers;
5
13
  export declare let method: string;
6
14
  export declare let params: Record<string, string>;
7
15
  export declare let slug: string | undefined;
16
+ /**
17
+ * Get request-scoped locals with full type safety.
18
+ * Define your Locals type in src/app.d.ts:
19
+ * ```
20
+ * declare global {
21
+ * namespace App {
22
+ * interface Locals {
23
+ * userId: number;
24
+ * userEmail: string;
25
+ * }
26
+ * }
27
+ * }
28
+ * ```
29
+ */
30
+ export declare function getLocals(): App.Locals;
31
+ /**
32
+ * Direct access to request-scoped locals.
33
+ * Type is inferred from App.Locals declared in your app.d.ts.
34
+ */
35
+ export declare const locals: App.Locals;
8
36
  export declare function __setRequestState(request: Request): void;
9
37
  export declare function __setRequestParams(nextParams: Record<string, string> | null | undefined): void;
@@ -1,3 +1,4 @@
1
+ import { __getLocals } from './context.js';
1
2
  export let url = new URL('http://localhost/');
2
3
  export let pathname = '/';
3
4
  export let searchParams = url.searchParams;
@@ -5,6 +6,49 @@ export let headers = new Headers();
5
6
  export let method = 'GET';
6
7
  export let params = {};
7
8
  export let slug = undefined;
9
+ /**
10
+ * Get request-scoped locals with full type safety.
11
+ * Define your Locals type in src/app.d.ts:
12
+ * ```
13
+ * declare global {
14
+ * namespace App {
15
+ * interface Locals {
16
+ * userId: number;
17
+ * userEmail: string;
18
+ * }
19
+ * }
20
+ * }
21
+ * ```
22
+ */
23
+ export function getLocals() {
24
+ return __getLocals();
25
+ }
26
+ /**
27
+ * Direct access to request-scoped locals.
28
+ * Type is inferred from App.Locals declared in your app.d.ts.
29
+ */
30
+ export const locals = new Proxy({}, {
31
+ get(_target, prop) {
32
+ return __getLocals()[prop];
33
+ },
34
+ set(_target, prop, value) {
35
+ __getLocals()[prop] = value;
36
+ return true;
37
+ },
38
+ has(_target, prop) {
39
+ return prop in __getLocals();
40
+ },
41
+ ownKeys() {
42
+ return Reflect.ownKeys(__getLocals());
43
+ },
44
+ getOwnPropertyDescriptor(_target, prop) {
45
+ const locals = __getLocals();
46
+ if (prop in locals) {
47
+ return { configurable: true, enumerable: true, value: locals[prop] };
48
+ }
49
+ return undefined;
50
+ },
51
+ });
8
52
  function __syncDerivedState() {
9
53
  pathname = url.pathname;
10
54
  searchParams = url.searchParams;