@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.
Files changed (40) hide show
  1. package/README.md +172 -9
  2. package/dist/compiler/client-module-pipeline.d.ts +8 -0
  3. package/dist/compiler/client-module-pipeline.js +181 -30
  4. package/dist/compiler/compiler-shared.d.ts +23 -0
  5. package/dist/compiler/config-reading.js +27 -1
  6. package/dist/compiler/convention-discovery.d.ts +2 -0
  7. package/dist/compiler/convention-discovery.js +16 -0
  8. package/dist/compiler/durable-object-pipeline.d.ts +1 -0
  9. package/dist/compiler/durable-object-pipeline.js +459 -119
  10. package/dist/compiler/import-linking.js +1 -1
  11. package/dist/compiler/index.js +41 -2
  12. package/dist/compiler/page-route-pipeline.js +31 -2
  13. package/dist/compiler/parser.d.ts +1 -0
  14. package/dist/compiler/parser.js +47 -4
  15. package/dist/compiler/root-layout-pipeline.js +26 -1
  16. package/dist/compiler/route-discovery.js +5 -5
  17. package/dist/compiler/route-pipeline.d.ts +2 -0
  18. package/dist/compiler/route-pipeline.js +28 -4
  19. package/dist/compiler/routes-module-feature-blocks.js +149 -17
  20. package/dist/compiler/routes-module-types.d.ts +1 -0
  21. package/dist/compiler/template.d.ts +4 -0
  22. package/dist/compiler/template.js +50 -18
  23. package/dist/compiler/worker-output-pipeline.js +2 -0
  24. package/dist/compiler/wrangler-sync.d.ts +3 -0
  25. package/dist/compiler/wrangler-sync.js +25 -11
  26. package/dist/create.js +6 -6
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +1 -0
  29. package/dist/runtime/context.d.ts +6 -0
  30. package/dist/runtime/context.js +22 -1
  31. package/dist/runtime/generated-worker.d.ts +1 -0
  32. package/dist/runtime/generated-worker.js +11 -7
  33. package/dist/runtime/index.d.ts +2 -0
  34. package/dist/runtime/index.js +1 -0
  35. package/dist/runtime/schema.d.ts +49 -0
  36. package/dist/runtime/schema.js +148 -0
  37. package/dist/runtime/types.d.ts +2 -0
  38. package/dist/runtime/validation.d.ts +26 -0
  39. package/dist/runtime/validation.js +147 -0
  40. package/package.json +5 -1
@@ -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;
@@ -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/create.js CHANGED
@@ -211,25 +211,25 @@ function scaffold(dir, opts) {
211
211
  write(dir, 'tsconfig.json', genTsConfig());
212
212
  write(dir, '.gitignore', genGitIgnore());
213
213
  write(dir, 'src/routes/layout.html', genLayout(opts));
214
- write(dir, 'src/routes/page.html', genLandingPage(opts));
214
+ write(dir, 'src/routes/index.html', genLandingPage(opts));
215
215
  if (orm) {
216
216
  write(dir, 'src/schemas/app.ts', genSchema(opts));
217
217
  write(dir, 'src/database/items.ts', genItemsCrud());
218
- write(dir, 'src/routes/items/page.html', genItemsPage());
218
+ write(dir, 'src/routes/items/index.html', genItemsPage());
219
219
  }
220
220
  if (enableDO) {
221
221
  write(dir, 'src/schemas/notes.ts', genNotesSchema());
222
222
  write(dir, 'src/server/notes.do.ts', genNotesDoHandler());
223
223
  write(dir, 'src/database/notes.ts', genNotesDb());
224
- write(dir, 'src/routes/notes/page.html', genNotesPage());
224
+ write(dir, 'src/routes/notes/index.html', genNotesPage());
225
225
  }
226
226
  if (auth) {
227
227
  write(dir, '.dev.vars', genDevVars());
228
228
  write(dir, 'src/database/auth.ts', genAuthFunctions());
229
229
  write(dir, 'src/database/admin.ts', genAdminLoader());
230
- write(dir, 'src/routes/auth/login/page.html', genLoginPage());
231
- write(dir, 'src/routes/auth/signup/page.html', genSignupPage());
232
- write(dir, 'src/routes/admin/page.html', genAdminPage());
230
+ write(dir, 'src/routes/auth/login/index.html', genLoginPage());
231
+ write(dir, 'src/routes/auth/signup/index.html', genSignupPage());
232
+ write(dir, 'src/routes/admin/index.html', genAdminPage());
233
233
  }
234
234
  }
235
235
  function write(dir, filePath, content) {
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 */
@@ -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();
@@ -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,49 @@
1
+ export declare class SchemaValidationError extends Error {
2
+ readonly isSchemaValidationError = true;
3
+ readonly status = 400;
4
+ constructor(message: string);
5
+ }
6
+ export interface SchemaType<T> {
7
+ parse(input: unknown, path?: string): T;
8
+ }
9
+ export type InferSchema<T extends SchemaType<any>> = T extends SchemaType<infer U> ? U : never;
10
+ declare class BaseSchema<T> implements SchemaType<T> {
11
+ protected readonly parser: (input: unknown, path: string) => T;
12
+ constructor(parser: (input: unknown, path: string) => T);
13
+ parse(input: unknown, path?: string): T;
14
+ optional(defaultValue?: T): SchemaType<T | undefined>;
15
+ list(): SchemaType<T[]>;
16
+ }
17
+ declare class StringSchema extends BaseSchema<string> {
18
+ constructor();
19
+ min(length: number): SchemaType<string>;
20
+ }
21
+ declare class NumberSchema extends BaseSchema<number> {
22
+ constructor();
23
+ min(value: number): SchemaType<number>;
24
+ }
25
+ declare class BooleanSchema extends BaseSchema<boolean> {
26
+ constructor();
27
+ }
28
+ declare class FileSchema extends BaseSchema<File> {
29
+ constructor();
30
+ }
31
+ type ShapeRecord = Record<string, SchemaType<any>>;
32
+ type InferShape<TShape extends ShapeRecord> = {
33
+ [K in keyof TShape]: InferSchema<TShape[K]>;
34
+ };
35
+ declare class ObjectSchema<TShape extends ShapeRecord> extends BaseSchema<InferShape<TShape>> {
36
+ readonly shape: TShape;
37
+ constructor(shape: TShape);
38
+ }
39
+ type SchemaBuilder = {
40
+ <TShape extends ShapeRecord>(shape: TShape): ObjectSchema<TShape>;
41
+ string(): StringSchema;
42
+ number(): NumberSchema;
43
+ boolean(): BooleanSchema;
44
+ file(): FileSchema;
45
+ };
46
+ export declare const schema: SchemaBuilder;
47
+ export declare function validateSchemaInput<T>(schemaDef: SchemaType<T> | undefined, args: unknown[]): unknown[];
48
+ export declare function parseRpcArgsPayload(payload: string | null): any[];
49
+ export {};
@@ -0,0 +1,148 @@
1
+ export class SchemaValidationError extends Error {
2
+ isSchemaValidationError = true;
3
+ status = 400;
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'SchemaValidationError';
7
+ }
8
+ }
9
+ function formatPath(path) {
10
+ return path || 'value';
11
+ }
12
+ function describe(value) {
13
+ if (value === null)
14
+ return 'null';
15
+ if (typeof File !== 'undefined' && value instanceof File)
16
+ return 'file';
17
+ if (Array.isArray(value))
18
+ return 'array';
19
+ return typeof value;
20
+ }
21
+ function fail(path, message) {
22
+ throw new SchemaValidationError(`${formatPath(path)} ${message}`);
23
+ }
24
+ function isPlainObject(value) {
25
+ return !!value && typeof value === 'object' && !Array.isArray(value) && !(typeof File !== 'undefined' && value instanceof File);
26
+ }
27
+ class BaseSchema {
28
+ parser;
29
+ constructor(parser) {
30
+ this.parser = parser;
31
+ }
32
+ parse(input, path = 'value') {
33
+ return this.parser(input, path);
34
+ }
35
+ optional(defaultValue) {
36
+ return new BaseSchema((input, path) => {
37
+ if (typeof input === 'undefined')
38
+ return defaultValue;
39
+ return this.parse(input, path);
40
+ });
41
+ }
42
+ list() {
43
+ return new BaseSchema((input, path) => {
44
+ if (!Array.isArray(input))
45
+ fail(path, `must be an array, got ${describe(input)}`);
46
+ return input.map((item, index) => this.parse(item, `${path}[${index}]`));
47
+ });
48
+ }
49
+ }
50
+ class StringSchema extends BaseSchema {
51
+ constructor() {
52
+ super((input, path) => {
53
+ if (typeof input !== 'string')
54
+ fail(path, `must be a string, got ${describe(input)}`);
55
+ return input;
56
+ });
57
+ }
58
+ min(length) {
59
+ return new BaseSchema((input, path) => {
60
+ const value = this.parse(input, path);
61
+ if (value.length < length)
62
+ fail(path, `must be at least ${length} character(s)`);
63
+ return value;
64
+ });
65
+ }
66
+ }
67
+ class NumberSchema extends BaseSchema {
68
+ constructor() {
69
+ super((input, path) => {
70
+ if (typeof input !== 'number' || Number.isNaN(input))
71
+ fail(path, `must be a number, got ${describe(input)}`);
72
+ return input;
73
+ });
74
+ }
75
+ min(value) {
76
+ return new BaseSchema((input, path) => {
77
+ const parsed = this.parse(input, path);
78
+ if (parsed < value)
79
+ fail(path, `must be at least ${value}`);
80
+ return parsed;
81
+ });
82
+ }
83
+ }
84
+ class BooleanSchema extends BaseSchema {
85
+ constructor() {
86
+ super((input, path) => {
87
+ if (typeof input !== 'boolean')
88
+ fail(path, `must be a boolean, got ${describe(input)}`);
89
+ return input;
90
+ });
91
+ }
92
+ }
93
+ class FileSchema extends BaseSchema {
94
+ constructor() {
95
+ super((input, path) => {
96
+ if (typeof File === 'undefined' || !(input instanceof File))
97
+ fail(path, `must be a file, got ${describe(input)}`);
98
+ return input;
99
+ });
100
+ }
101
+ }
102
+ class ObjectSchema extends BaseSchema {
103
+ shape;
104
+ constructor(shape) {
105
+ super((input, path) => {
106
+ if (!isPlainObject(input))
107
+ fail(path, `must be an object, got ${describe(input)}`);
108
+ const out = {};
109
+ for (const key of Object.keys(shape)) {
110
+ out[key] = shape[key].parse(input[key], `${path}.${key}`);
111
+ }
112
+ return out;
113
+ });
114
+ this.shape = shape;
115
+ }
116
+ }
117
+ function createSchema(shape) {
118
+ return new ObjectSchema(shape);
119
+ }
120
+ export const schema = Object.assign(createSchema, {
121
+ string: () => new StringSchema(),
122
+ number: () => new NumberSchema(),
123
+ boolean: () => new BooleanSchema(),
124
+ file: () => new FileSchema(),
125
+ });
126
+ export function validateSchemaInput(schemaDef, args) {
127
+ if (!schemaDef)
128
+ return args;
129
+ if (args.length !== 1) {
130
+ throw new SchemaValidationError(`validated RPCs must receive exactly one argument object, got ${args.length}`);
131
+ }
132
+ return [schemaDef.parse(args[0], 'data')];
133
+ }
134
+ export function parseRpcArgsPayload(payload) {
135
+ if (!payload)
136
+ return [];
137
+ let parsed;
138
+ try {
139
+ parsed = JSON.parse(payload);
140
+ }
141
+ catch {
142
+ throw new SchemaValidationError('args must be valid JSON');
143
+ }
144
+ if (!Array.isArray(parsed)) {
145
+ throw new SchemaValidationError('args must be a JSON array');
146
+ }
147
+ return parsed;
148
+ }
@@ -34,6 +34,8 @@ export interface RouteModule {
34
34
  actions?: Record<string, (formData: FormData, env: Env, ctx: RouteContext) => Promise<any>>;
35
35
  /** RPC functions â€" callable from client via fetch */
36
36
  rpc?: Record<string, (args: any[], env: Env, ctx: RouteContext) => Promise<any>>;
37
+ /** Optional schemas for RPC inputs, keyed by function name */
38
+ rpcSchemas?: Record<string, any>;
37
39
  /** Render function â€" returns route HTML and optional head content from data */
38
40
  render: (data: Record<string, any>) => PageRenderOutput;
39
41
  /** Layout name (default: 'default') */
@@ -0,0 +1,26 @@
1
+ export declare class RpcValidationError extends Error {
2
+ readonly isRpcValidationError = true;
3
+ readonly status = 400;
4
+ constructor(message: string);
5
+ }
6
+ export interface Validator<T> {
7
+ parse(input: unknown, path?: string): T;
8
+ }
9
+ export type InferValidator<T extends Validator<any>> = T extends Validator<infer U> ? U : never;
10
+ export declare const v: {
11
+ string(): Validator<string>;
12
+ number(): Validator<number>;
13
+ boolean(): Validator<boolean>;
14
+ literal<const T extends string | number | boolean | null>(expected: T): Validator<T>;
15
+ optional<T>(inner: Validator<T>): Validator<T | undefined>;
16
+ nullable<T>(inner: Validator<T>): Validator<T | null>;
17
+ array<T>(inner: Validator<T>): Validator<T[]>;
18
+ object<TShape extends Record<string, Validator<any>>>(shape: TShape): Validator<{ [K in keyof TShape]: InferValidator<TShape[K]>; }>;
19
+ tuple<TItems extends readonly Validator<any>[]>(items: TItems): Validator<{ [K in keyof TItems]: InferValidator<TItems[K]>; }>;
20
+ union<TItems extends readonly Validator<any>[]>(items: TItems): Validator<InferValidator<TItems[number]>>;
21
+ };
22
+ export declare function rpc<TArgs extends any[], TResult>(validator: Validator<TArgs>, handler: (...args: TArgs) => Promise<TResult> | TResult): ((...args: TArgs) => Promise<TResult> | TResult) & {
23
+ __kuratchiRpcValidator: Validator<TArgs>;
24
+ };
25
+ export declare function validateRpcArgs(fn: (...args: any[]) => Promise<unknown> | unknown, args: unknown[]): any[];
26
+ export declare function parseRpcArgsPayload(payload: string | null): any[];