@kuratchi/js 0.0.16 → 0.0.18

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 (38) hide show
  1. package/README.md +168 -11
  2. package/dist/cli.js +13 -13
  3. package/dist/compiler/client-module-pipeline.js +5 -5
  4. package/dist/compiler/compiler-shared.d.ts +18 -0
  5. package/dist/compiler/component-pipeline.js +4 -9
  6. package/dist/compiler/config-reading.d.ts +2 -1
  7. package/dist/compiler/config-reading.js +57 -0
  8. package/dist/compiler/durable-object-pipeline.js +1 -1
  9. package/dist/compiler/import-linking.js +2 -1
  10. package/dist/compiler/index.d.ts +6 -6
  11. package/dist/compiler/index.js +57 -23
  12. package/dist/compiler/layout-pipeline.js +6 -6
  13. package/dist/compiler/parser.js +10 -11
  14. package/dist/compiler/root-layout-pipeline.js +444 -429
  15. package/dist/compiler/route-pipeline.js +36 -41
  16. package/dist/compiler/route-state-pipeline.d.ts +1 -0
  17. package/dist/compiler/route-state-pipeline.js +3 -3
  18. package/dist/compiler/routes-module-feature-blocks.js +63 -63
  19. package/dist/compiler/routes-module-runtime-shell.js +65 -55
  20. package/dist/compiler/routes-module-types.d.ts +2 -1
  21. package/dist/compiler/server-module-pipeline.js +1 -1
  22. package/dist/compiler/template.js +24 -15
  23. package/dist/compiler/worker-output-pipeline.d.ts +1 -0
  24. package/dist/compiler/worker-output-pipeline.js +10 -2
  25. package/dist/create.js +1 -1
  26. package/dist/runtime/context.d.ts +4 -0
  27. package/dist/runtime/context.js +40 -2
  28. package/dist/runtime/do.js +21 -6
  29. package/dist/runtime/generated-worker.d.ts +22 -0
  30. package/dist/runtime/generated-worker.js +154 -23
  31. package/dist/runtime/index.d.ts +3 -1
  32. package/dist/runtime/index.js +1 -0
  33. package/dist/runtime/router.d.ts +5 -1
  34. package/dist/runtime/router.js +116 -31
  35. package/dist/runtime/security.d.ts +101 -0
  36. package/dist/runtime/security.js +312 -0
  37. package/dist/runtime/types.d.ts +21 -0
  38. package/package.json +1 -1
@@ -30,6 +30,10 @@ export function __getDoSelf() {
30
30
  }
31
31
  const _resolvers = new Map();
32
32
  const _classBindings = new WeakMap();
33
+ const _rpcProxyCache = new WeakMap();
34
+ function __isDevMode() {
35
+ return !!globalThis.__kuratchi_DEV__;
36
+ }
33
37
  /** @internal — called by compiler-generated init code */
34
38
  export function __registerDoResolver(binding, resolver) {
35
39
  _resolvers.set(binding, resolver);
@@ -94,36 +98,47 @@ export class kuratchiDO {
94
98
  */
95
99
  static rpc() {
96
100
  const klass = this;
97
- return new Proxy({}, {
101
+ // Cache proxy per class to avoid repeated Proxy creation
102
+ let cached = _rpcProxyCache.get(klass);
103
+ if (cached)
104
+ return cached;
105
+ const proxy = new Proxy({}, {
98
106
  get(_, method) {
99
107
  return async (...args) => {
100
108
  const binding = this.binding || _classBindings.get(klass);
101
109
  if (!binding) {
102
110
  throw new Error(`[KuratchiJS] Missing DO binding for class '${this?.name || 'UnknownDO'}'. Add static binding or ensure compiler binding registration is active.`);
103
111
  }
104
- console.log(`[rpc] ${binding}.${method}() — resolving stub...`);
112
+ if (__isDevMode())
113
+ console.log(`[rpc] ${binding}.${method}() — resolving stub...`);
105
114
  const stub = await __getDoStub(binding);
106
115
  if (!stub) {
107
116
  throw new Error(`[KuratchiJS] Not authenticated — cannot call '${method}' on ${binding}`);
108
117
  }
109
- console.log(`[rpc] ${binding}.${method}() — stub type: ${stub?.constructor?.name ?? typeof stub}`);
110
- console.log(`[rpc] ${binding}.${method}() — calling with ${args.length} arg(s)...`);
118
+ if (__isDevMode()) {
119
+ console.log(`[rpc] ${binding}.${method}() — stub type: ${stub?.constructor?.name ?? typeof stub}`);
120
+ console.log(`[rpc] ${binding}.${method}() — calling with ${args.length} arg(s)...`);
121
+ }
111
122
  try {
112
123
  // Call method directly on the stub — DO NOT detach with stub[method]
113
124
  // then .apply(). Workers RPC stubs are Proxy-based; detaching breaks
114
125
  // the runtime's interception and causes DataCloneError trying to
115
126
  // serialize the DurableObject as `this`.
116
127
  const result = await stub[method](...args);
117
- console.log(`[rpc] ${binding}.${method}() — returned, result type: ${typeof result}`);
128
+ if (__isDevMode())
129
+ console.log(`[rpc] ${binding}.${method}() — returned, result type: ${typeof result}`);
118
130
  return result;
119
131
  }
120
132
  catch (err) {
121
- console.error(`[rpc] ${binding}.${method}() — THREW: ${err.message}`);
133
+ if (__isDevMode())
134
+ console.error(`[rpc] ${binding}.${method}() — THREW: ${err.message}`);
122
135
  throw err;
123
136
  }
124
137
  };
125
138
  },
126
139
  });
140
+ _rpcProxyCache.set(klass, proxy);
141
+ return proxy;
127
142
  }
128
143
  }
129
144
  /**
@@ -14,8 +14,28 @@ 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
+ /** Allowed query function names for this route (for query override validation) */
18
+ allowedQueries?: string[];
17
19
  render: (data: Record<string, any>) => PageRenderOutput;
18
20
  }
21
+ export interface SecurityOptions {
22
+ /** Enable CSRF protection (default: true) */
23
+ csrfEnabled?: boolean;
24
+ /** CSRF cookie name (default: '__kuratchi_csrf') */
25
+ csrfCookieName?: string;
26
+ /** CSRF header name (default: 'x-kuratchi-csrf') */
27
+ csrfHeaderName?: string;
28
+ /** Require authentication for RPC (default: false) */
29
+ rpcRequireAuth?: boolean;
30
+ /** Require authentication for form actions (default: false) */
31
+ actionRequireAuth?: boolean;
32
+ /** Content Security Policy directive string */
33
+ contentSecurityPolicy?: string | null;
34
+ /** Strict-Transport-Security header value */
35
+ strictTransportSecurity?: string | null;
36
+ /** Permissions-Policy header value */
37
+ permissionsPolicy?: string | null;
38
+ }
19
39
  export interface GeneratedWorkerOptions {
20
40
  routes: Array<GeneratedPageRoute | GeneratedApiRoute>;
21
41
  layout: (content: string, head?: string) => Promise<string> | string;
@@ -27,6 +47,8 @@ export interface GeneratedWorkerOptions {
27
47
  workflowStatusRpc?: Record<string, (instanceId: string) => Promise<unknown>>;
28
48
  initializeRequest?: (ctx: RuntimeContext) => Promise<void> | void;
29
49
  preRouteChecks?: (ctx: RuntimeContext) => Promise<Response | null | undefined> | Response | null | undefined;
50
+ /** Security configuration */
51
+ security?: SecurityOptions;
30
52
  }
31
53
  export declare function createGeneratedWorker(opts: GeneratedWorkerOptions): {
32
54
  fetch(request: Request, env: Record<string, any>, ctx: ExecutionContext): Promise<Response>;
@@ -1,11 +1,25 @@
1
1
  import { __esc, __getLocals, __setLocal, __setRequestContext, buildDefaultBreadcrumbs } from './context.js';
2
2
  import { Router } from './router.js';
3
+ import { initCsrf, getCsrfCookieHeader, validateRpcRequest, validateActionRequest, validateSignedFragment, validateQueryOverride, parseQueryArgs, CSRF_DEFAULTS, } from './security.js';
3
4
  export function createGeneratedWorker(opts) {
4
5
  const router = new Router();
5
6
  const runtimeEntries = __getRuntimeEntries(opts.runtimeDefinition);
6
7
  for (let i = 0; i < opts.routes.length; i++) {
7
8
  router.add(opts.routes[i].pattern, i);
8
9
  }
10
+ // Security configuration with defaults
11
+ const securityConfig = {
12
+ csrfEnabled: opts.security?.csrfEnabled ?? true,
13
+ csrfCookieName: opts.security?.csrfCookieName ?? CSRF_DEFAULTS.cookieName,
14
+ csrfHeaderName: opts.security?.csrfHeaderName ?? CSRF_DEFAULTS.headerName,
15
+ rpcRequireAuth: opts.security?.rpcRequireAuth ?? false,
16
+ actionRequireAuth: opts.security?.actionRequireAuth ?? false,
17
+ contentSecurityPolicy: opts.security?.contentSecurityPolicy ?? null,
18
+ strictTransportSecurity: opts.security?.strictTransportSecurity ?? null,
19
+ permissionsPolicy: opts.security?.permissionsPolicy ?? null,
20
+ };
21
+ // Initialize configurable security headers
22
+ __initSecurityHeaders(securityConfig);
9
23
  return {
10
24
  async fetch(request, env, ctx) {
11
25
  __setRequestContext(ctx, request, env);
@@ -17,12 +31,17 @@ export function createGeneratedWorker(opts) {
17
31
  params: {},
18
32
  locals: __getLocals(),
19
33
  };
34
+ // Initialize CSRF token for the request
35
+ if (securityConfig.csrfEnabled) {
36
+ initCsrf(request, securityConfig.csrfCookieName);
37
+ }
20
38
  if (opts.initializeRequest) {
21
39
  await opts.initializeRequest(runtimeCtx);
22
40
  }
23
41
  const coreFetch = async () => {
24
42
  const { url } = runtimeCtx;
25
- const fragmentId = request.headers.get('x-kuratchi-fragment');
43
+ const signedFragmentId = request.headers.get('x-kuratchi-fragment');
44
+ let fragmentId = null;
26
45
  const preRoute = opts.preRouteChecks ? await opts.preRouteChecks(runtimeCtx) : null;
27
46
  if (preRoute instanceof Response) {
28
47
  return __secHeaders(preRoute);
@@ -53,28 +72,58 @@ export function createGeneratedWorker(opts) {
53
72
  }
54
73
  runtimeCtx.params = match.params;
55
74
  __setLocal('params', match.params);
75
+ __setLocal('__currentRoutePath', url.pathname);
56
76
  const route = opts.routes[match.index];
57
77
  if ('__api' in route && route.__api) {
58
78
  return __dispatchApiRoute(route, runtimeCtx);
59
79
  }
60
80
  const pageRoute = route;
81
+ // Validate fragment ID if present
82
+ if (signedFragmentId) {
83
+ const fragmentValidation = validateSignedFragment(signedFragmentId, url.pathname);
84
+ if (!fragmentValidation.valid) {
85
+ return __secHeaders(new Response(fragmentValidation.reason || 'Invalid fragment', { status: 403 }));
86
+ }
87
+ fragmentId = fragmentValidation.fragmentId;
88
+ }
89
+ // Validate and parse query override if present
61
90
  const queryFn = request.headers.get('x-kuratchi-query-fn') || '';
62
91
  const queryArgsRaw = request.headers.get('x-kuratchi-query-args') || '[]';
63
92
  let queryArgs = [];
64
- try {
65
- const parsed = JSON.parse(queryArgsRaw);
66
- queryArgs = Array.isArray(parsed) ? parsed : [];
93
+ if (queryFn) {
94
+ // Validate query function is allowed for this route
95
+ const allowedQueries = pageRoute.allowedQueries || [];
96
+ // Also allow RPC functions as queries
97
+ const rpcFunctions = pageRoute.rpc ? Object.keys(pageRoute.rpc) : [];
98
+ const allAllowed = [...allowedQueries, ...rpcFunctions];
99
+ if (allAllowed.length > 0) {
100
+ const queryValidation = validateQueryOverride(queryFn, allAllowed);
101
+ if (!queryValidation.valid) {
102
+ return __secHeaders(new Response(JSON.stringify({ ok: false, error: queryValidation.reason }), {
103
+ status: 403,
104
+ headers: { 'content-type': 'application/json' },
105
+ }));
106
+ }
107
+ }
108
+ // Parse and validate query arguments
109
+ const argsValidation = parseQueryArgs(queryArgsRaw);
110
+ if (!argsValidation.valid) {
111
+ return __secHeaders(new Response(JSON.stringify({ ok: false, error: argsValidation.reason }), {
112
+ status: 400,
113
+ headers: { 'content-type': 'application/json' },
114
+ }));
115
+ }
116
+ queryArgs = argsValidation.args;
67
117
  }
68
- catch { }
69
118
  __setLocal('__queryOverride', queryFn ? { fn: queryFn, args: queryArgs } : null);
70
119
  if (!__getLocals().__breadcrumbs) {
71
120
  __setLocal('breadcrumbs', buildDefaultBreadcrumbs(url.pathname, match.params));
72
121
  }
73
- const rpcResponse = await __handleRpc(pageRoute, opts.workflowStatusRpc ?? {}, runtimeCtx);
122
+ const rpcResponse = await __handleRpc(pageRoute, opts.workflowStatusRpc ?? {}, runtimeCtx, securityConfig);
74
123
  if (rpcResponse)
75
124
  return rpcResponse;
76
125
  if (request.method === 'POST') {
77
- const actionResponse = await __handleAction(pageRoute, opts.layoutActions, opts.layout, runtimeCtx, fragmentId);
126
+ const actionResponse = await __handleAction(pageRoute, opts.layoutActions, opts.layout, runtimeCtx, fragmentId, securityConfig);
78
127
  if (actionResponse)
79
128
  return actionResponse;
80
129
  }
@@ -101,7 +150,7 @@ export function createGeneratedWorker(opts) {
101
150
  return __secHeaders(handled);
102
151
  console.error('[kuratchi] Route load/render error:', err);
103
152
  const pageErrStatus = err?.isPageError && err.status ? err.status : 500;
104
- const errDetail = err?.isPageError ? err.message : (__isDevMode() && err?.message) ? err.message : undefined;
153
+ const errDetail = __sanitizeErrorDetail(err);
105
154
  return __secHeaders(new Response(await opts.layout(__renderError(opts.errorPages, pageErrStatus, errDetail)), {
106
155
  status: pageErrStatus,
107
156
  headers: { 'content-type': 'text/html; charset=utf-8' },
@@ -147,7 +196,7 @@ async function __dispatchApiRoute(route, runtimeCtx) {
147
196
  }
148
197
  return __secHeaders(await handler(runtimeCtx));
149
198
  }
150
- async function __handleRpc(route, workflowStatusRpc, runtimeCtx) {
199
+ async function __handleRpc(route, workflowStatusRpc, runtimeCtx, securityConfig) {
151
200
  const { request, url } = runtimeCtx;
152
201
  const rpcName = url.searchParams.get('_rpc');
153
202
  const hasRouteRpc = rpcName && route.rpc && Object.hasOwn(route.rpc, rpcName);
@@ -155,8 +204,22 @@ async function __handleRpc(route, workflowStatusRpc, runtimeCtx) {
155
204
  if (!(request.method === 'GET' && rpcName && (hasRouteRpc || hasWorkflowRpc))) {
156
205
  return null;
157
206
  }
207
+ // Validate RPC request security
208
+ const rpcSecConfig = {
209
+ requireAuth: securityConfig.rpcRequireAuth,
210
+ validateCsrf: securityConfig.csrfEnabled,
211
+ allowedMethods: ['GET', 'POST'],
212
+ };
213
+ const rpcValidation = validateRpcRequest(request, rpcSecConfig);
214
+ if (!rpcValidation.valid) {
215
+ return __secHeaders(new Response(JSON.stringify({ ok: false, error: rpcValidation.reason || 'Forbidden' }), {
216
+ status: rpcValidation.status,
217
+ headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
218
+ }));
219
+ }
220
+ // Legacy header check (still required for backward compatibility)
158
221
  if (request.headers.get('x-kuratchi-rpc') !== '1') {
159
- return __secHeaders(new Response(JSON.stringify({ ok: false, error: 'Forbidden' }), {
222
+ return __secHeaders(new Response(JSON.stringify({ ok: false, error: 'Missing RPC header' }), {
160
223
  status: 403,
161
224
  headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
162
225
  }));
@@ -170,27 +233,41 @@ async function __handleRpc(route, workflowStatusRpc, runtimeCtx) {
170
233
  }
171
234
  const rpcFn = hasRouteRpc ? route.rpc[rpcName] : workflowStatusRpc[rpcName];
172
235
  const rpcResult = await rpcFn(...rpcArgs);
173
- return __secHeaders(new Response(JSON.stringify({ ok: true, data: rpcResult }), {
236
+ return __attachCookies(new Response(JSON.stringify({ ok: true, data: rpcResult }), {
174
237
  headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
175
238
  }));
176
239
  }
177
240
  catch (err) {
178
241
  console.error('[kuratchi] RPC error:', err);
179
- const errMsg = __isDevMode() ? err?.message : 'Internal Server Error';
242
+ const errMsg = __sanitizeErrorMessage(err);
180
243
  return __secHeaders(new Response(JSON.stringify({ ok: false, error: errMsg }), {
181
244
  status: 500,
182
245
  headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
183
246
  }));
184
247
  }
185
248
  }
186
- async function __handleAction(route, layoutActions, layout, runtimeCtx, fragmentId) {
249
+ async function __handleAction(route, layoutActions, layout, runtimeCtx, fragmentId, securityConfig) {
187
250
  const { request, url, params } = runtimeCtx;
188
251
  if (request.method !== 'POST')
189
252
  return null;
190
- if (!__isSameOrigin(request, url)) {
191
- return __secHeaders(new Response('Forbidden', { status: 403 }));
192
- }
193
253
  const formData = await request.formData();
254
+ // Validate action request security
255
+ const actionSecConfig = {
256
+ validateCsrf: securityConfig.csrfEnabled,
257
+ requireSameOrigin: true,
258
+ };
259
+ const actionValidation = validateActionRequest(request, url, formData, actionSecConfig);
260
+ if (!actionValidation.valid) {
261
+ return __secHeaders(new Response(actionValidation.reason || 'Forbidden', { status: actionValidation.status }));
262
+ }
263
+ // Check authentication if required
264
+ if (securityConfig.actionRequireAuth) {
265
+ const locals = __getLocals();
266
+ const user = locals.user || locals.session?.user;
267
+ if (!user) {
268
+ return __secHeaders(new Response('Authentication required', { status: 401 }));
269
+ }
270
+ }
194
271
  const actionName = formData.get('_action');
195
272
  const actionKey = typeof actionName === 'string' ? actionName : null;
196
273
  const actionFn = (actionKey && route.actions && Object.hasOwn(route.actions, actionKey) ? route.actions[actionKey] : null)
@@ -223,7 +300,7 @@ async function __handleAction(route, layoutActions, layout, runtimeCtx, fragment
223
300
  }
224
301
  console.error('[kuratchi] Action error:', err);
225
302
  if (isFetchAction) {
226
- const errMsg = __isDevMode() && err?.message ? err.message : 'Internal Server Error';
303
+ const errMsg = __sanitizeErrorMessage(err);
227
304
  return __secHeaders(new Response(JSON.stringify({ ok: false, error: errMsg }), {
228
305
  status: 500,
229
306
  headers: { 'content-type': 'application/json' },
@@ -238,7 +315,7 @@ async function __handleAction(route, layoutActions, layout, runtimeCtx, fragment
238
315
  if (!(key in data))
239
316
  data[key] = { error: undefined, loading: false, success: false };
240
317
  });
241
- const errMsg = err?.isActionError ? err.message : (__isDevMode() && err?.message) ? err.message : 'Action failed';
318
+ const errMsg = __sanitizeErrorMessage(err, 'Action failed');
242
319
  data[actionKey] = { error: errMsg, loading: false, success: false };
243
320
  return await __renderPage(layout, route, data, fragmentId);
244
321
  }
@@ -299,18 +376,26 @@ function __isSameOrigin(request, url) {
299
376
  }
300
377
  }
301
378
  function __secHeaders(response) {
302
- for (const [key, value] of Object.entries(__defaultSecHeaders)) {
379
+ for (const [key, value] of Object.entries(__configuredSecHeaders)) {
303
380
  if (!response.headers.has(key))
304
381
  response.headers.set(key, value);
305
382
  }
306
383
  return response;
307
384
  }
308
385
  function __attachCookies(response) {
309
- const cookies = __getLocals().__setCookieHeaders;
310
- if (cookies && cookies.length > 0) {
386
+ const locals = __getLocals();
387
+ const cookies = locals.__setCookieHeaders;
388
+ const csrfCookie = getCsrfCookieHeader();
389
+ const hasCookies = (cookies && cookies.length > 0) || csrfCookie;
390
+ if (hasCookies) {
311
391
  const newResponse = new Response(response.body, response);
312
- for (const header of cookies)
313
- newResponse.headers.append('Set-Cookie', header);
392
+ if (cookies) {
393
+ for (const header of cookies)
394
+ newResponse.headers.append('Set-Cookie', header);
395
+ }
396
+ if (csrfCookie) {
397
+ newResponse.headers.append('Set-Cookie', csrfCookie);
398
+ }
314
399
  return __secHeaders(newResponse);
315
400
  }
316
401
  return __secHeaders(response);
@@ -393,11 +478,57 @@ function __getRuntimeEntries(runtimeDefinition) {
393
478
  function __isDevMode() {
394
479
  return !!globalThis.__kuratchi_DEV__;
395
480
  }
481
+ /**
482
+ * Sanitize error messages for client responses.
483
+ * In production, only expose safe error messages to prevent information leakage.
484
+ * In dev mode, expose full error details for debugging.
485
+ */
486
+ function __sanitizeErrorMessage(err, fallback = 'Internal Server Error') {
487
+ // Always allow explicit user-facing errors (ActionError, PageError)
488
+ if (err?.isActionError || err?.isPageError) {
489
+ return err.message || fallback;
490
+ }
491
+ // In dev mode, expose full error message for debugging
492
+ if (__isDevMode() && err?.message) {
493
+ return err.message;
494
+ }
495
+ // In production, use generic message to prevent information leakage
496
+ return fallback;
497
+ }
498
+ /**
499
+ * Sanitize error details for HTML error pages.
500
+ * Returns undefined in production to hide error details.
501
+ */
502
+ function __sanitizeErrorDetail(err) {
503
+ // PageError messages are always safe to show
504
+ if (err?.isPageError) {
505
+ return err.message;
506
+ }
507
+ // In dev mode, show error details
508
+ if (__isDevMode() && err?.message) {
509
+ return err.message;
510
+ }
511
+ // In production, hide error details
512
+ return undefined;
513
+ }
396
514
  const __defaultSecHeaders = {
397
515
  'X-Content-Type-Options': 'nosniff',
398
516
  'X-Frame-Options': 'DENY',
399
517
  'Referrer-Policy': 'strict-origin-when-cross-origin',
400
518
  };
519
+ let __configuredSecHeaders = { ...__defaultSecHeaders };
520
+ function __initSecurityHeaders(config) {
521
+ __configuredSecHeaders = { ...__defaultSecHeaders };
522
+ if (config.contentSecurityPolicy) {
523
+ __configuredSecHeaders['Content-Security-Policy'] = config.contentSecurityPolicy;
524
+ }
525
+ if (config.strictTransportSecurity) {
526
+ __configuredSecHeaders['Strict-Transport-Security'] = config.strictTransportSecurity;
527
+ }
528
+ if (config.permissionsPolicy) {
529
+ __configuredSecHeaders['Permissions-Policy'] = config.permissionsPolicy;
530
+ }
531
+ }
401
532
  const __errorMessages = {
402
533
  400: 'Bad Request',
403
534
  401: 'Unauthorized',
@@ -5,7 +5,9 @@ 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 { initCsrf, getCsrfToken, validateCsrf, getCsrfCookieHeader, validateRpcRequest, validateActionRequest, applySecurityHeaders, signFragmentId, validateSignedFragment, validateQueryOverride, parseQueryArgs, CSRF_DEFAULTS, } from './security.js';
9
+ export type { RpcSecurityConfig, ActionSecurityConfig, SecurityHeadersConfig, } from './security.js';
8
10
  export { extractSubdomainSlug, extractSlugFromPrefix, matchContainerViewPath, rewriteProxyLocationHeader, buildContainerRequest, createContainerEnvVars, startContainer, proxyToContainer, handleContainerRouting, forwardJsonPostToContainerDO, matchSiteViewPath, buildSiteContainerRequest, createWpContainerEnvVars, startSiteContainer, proxyToSiteContainer, } from './containers.js';
9
- export type { AppConfig, Env, AuthConfig, RouteContext, RouteModule, ApiRouteModule, HttpMethod, LayoutModule, PageRenderOutput, PageRenderResult, RuntimeContext, RuntimeDefinition, RuntimeStep, RuntimeNext, RuntimeErrorResult, } from './types.js';
11
+ export type { AppConfig, Env, AuthConfig, SecurityConfig, RouteContext, RouteModule, ApiRouteModule, HttpMethod, LayoutModule, PageRenderOutput, PageRenderResult, RuntimeContext, RuntimeDefinition, RuntimeStep, RuntimeNext, RuntimeErrorResult, } from './types.js';
10
12
  export type { RpcOf } from './do.js';
11
13
  export { url, pathname, searchParams, headers, method, params, slug } from './request.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 { initCsrf, getCsrfToken, validateCsrf, getCsrfCookieHeader, validateRpcRequest, validateActionRequest, applySecurityHeaders, signFragmentId, validateSignedFragment, validateQueryOverride, parseQueryArgs, CSRF_DEFAULTS, } from './security.js';
8
9
  export { extractSubdomainSlug, extractSlugFromPrefix, matchContainerViewPath, rewriteProxyLocationHeader, buildContainerRequest, createContainerEnvVars, startContainer, proxyToContainer, handleContainerRouting, forwardJsonPostToContainerDO,
9
10
  // Compatibility aliases
10
11
  matchSiteViewPath, buildSiteContainerRequest, createWpContainerEnvVars, startSiteContainer, proxyToSiteContainer, } from './containers.js';
@@ -5,6 +5,8 @@
5
5
  * /todos → static
6
6
  * /blog/:slug → named param
7
7
  * /files/*rest → catch-all
8
+ *
9
+ * Uses a radix tree for O(log n) dynamic route matching instead of O(n) linear scan.
8
10
  */
9
11
  export interface MatchResult {
10
12
  params: Record<string, string>;
@@ -12,11 +14,13 @@ export interface MatchResult {
12
14
  }
13
15
  export declare class Router {
14
16
  private staticRoutes;
15
- private dynamicRoutes;
17
+ private root;
16
18
  /** Register a pattern (e.g. '/blog/:slug') and associate it with an index. */
17
19
  add(pattern: string, index: number): void;
18
20
  /** Match a pathname against registered routes. Returns null if no match. */
19
21
  match(pathname: string): MatchResult | null;
22
+ /** Recursive radix tree matching with backtracking */
23
+ private matchNode;
20
24
  }
21
25
  /**
22
26
  * Convert a file-system path to a route pattern.
@@ -5,56 +5,141 @@
5
5
  * /todos → static
6
6
  * /blog/:slug → named param
7
7
  * /files/*rest → catch-all
8
+ *
9
+ * Uses a radix tree for O(log n) dynamic route matching instead of O(n) linear scan.
8
10
  */
11
+ function createNode(segment, type, paramName) {
12
+ return {
13
+ segment,
14
+ type,
15
+ paramName,
16
+ children: new Map(),
17
+ };
18
+ }
9
19
  export class Router {
10
20
  staticRoutes = new Map();
11
- dynamicRoutes = [];
21
+ root = createNode('', 0 /* NodeType.Static */);
12
22
  /** Register a pattern (e.g. '/blog/:slug') and associate it with an index. */
13
23
  add(pattern, index) {
14
24
  if (!pattern.includes(':') && !pattern.includes('*')) {
15
25
  this.staticRoutes.set(pattern, index);
16
26
  return;
17
27
  }
18
- const paramNames = [];
19
- // Convert pattern to regex
20
- // :param → named capture group
21
- // *param → catch-all capture group
22
- let regexStr = pattern
23
- // Catch-all: /files/*rest → /files/(?<rest>.+)
24
- .replace(/\*(\w+)/g, (_match, name) => {
25
- paramNames.push(name);
26
- return `(?<${name}>.+)`;
27
- })
28
- // Named params: /blog/:slug → /blog/(?<slug>[^/]+)
29
- .replace(/:(\w+)/g, (_match, name) => {
30
- paramNames.push(name);
31
- return `(?<${name}>[^/]+)`;
32
- });
33
- // Anchor
34
- regexStr = `^${regexStr}$`;
35
- this.dynamicRoutes.push({
36
- regex: new RegExp(regexStr),
37
- paramNames,
38
- index,
39
- });
28
+ // Parse pattern into segments
29
+ const segments = pattern.split('/').filter(Boolean);
30
+ let node = this.root;
31
+ for (let i = 0; i < segments.length; i++) {
32
+ const seg = segments[i];
33
+ if (seg.startsWith('*')) {
34
+ // Catch-all: *param
35
+ const paramName = seg.slice(1);
36
+ if (!node.catchAllChild) {
37
+ node.catchAllChild = createNode('', 2 /* NodeType.CatchAll */, paramName);
38
+ }
39
+ node = node.catchAllChild;
40
+ // Catch-all must be last segment
41
+ break;
42
+ }
43
+ else if (seg.startsWith(':')) {
44
+ // Param: :param
45
+ const paramName = seg.slice(1);
46
+ if (!node.paramChild) {
47
+ node.paramChild = createNode('', 1 /* NodeType.Param */, paramName);
48
+ }
49
+ node = node.paramChild;
50
+ }
51
+ else {
52
+ // Static segment
53
+ const key = seg[0] || '';
54
+ let child = node.children.get(key);
55
+ if (!child) {
56
+ child = createNode(seg, 0 /* NodeType.Static */);
57
+ node.children.set(key, child);
58
+ }
59
+ else if (child.segment !== seg) {
60
+ // Handle prefix splitting for true radix tree (simplified: exact match only)
61
+ // For simplicity, we use segment-level matching which is sufficient for route patterns
62
+ let found = false;
63
+ for (const [, c] of node.children) {
64
+ if (c.segment === seg) {
65
+ child = c;
66
+ found = true;
67
+ break;
68
+ }
69
+ }
70
+ if (!found) {
71
+ child = createNode(seg, 0 /* NodeType.Static */);
72
+ // Use full segment as key for collision handling
73
+ node.children.set(seg, child);
74
+ }
75
+ }
76
+ node = child;
77
+ }
78
+ }
79
+ node.routeIndex = index;
40
80
  }
41
81
  /** Match a pathname against registered routes. Returns null if no match. */
42
82
  match(pathname) {
43
83
  // Normalize: strip trailing slash (except root)
44
84
  const normalized = pathname === '/' ? '/' : pathname.replace(/\/$/, '');
85
+ // Fast path: static routes (O(1))
45
86
  const staticIdx = this.staticRoutes.get(normalized);
46
87
  if (staticIdx !== undefined) {
47
88
  return { params: {}, index: staticIdx };
48
89
  }
49
- for (const route of this.dynamicRoutes) {
50
- const m = normalized.match(route.regex);
51
- if (m) {
52
- const params = {};
53
- for (const name of route.paramNames) {
54
- params[name] = m.groups?.[name] ?? '';
55
- }
56
- return { params, index: route.index };
90
+ // Radix tree traversal for dynamic routes
91
+ const segments = normalized.split('/').filter(Boolean);
92
+ const params = {};
93
+ const result = this.matchNode(this.root, segments, 0, params);
94
+ if (result !== null) {
95
+ return { params, index: result };
96
+ }
97
+ return null;
98
+ }
99
+ /** Recursive radix tree matching with backtracking */
100
+ matchNode(node, segments, segIdx, params) {
101
+ // Base case: consumed all segments
102
+ if (segIdx >= segments.length) {
103
+ return node.routeIndex ?? null;
104
+ }
105
+ const seg = segments[segIdx];
106
+ // 1. Try static children first (highest priority)
107
+ // Check by first char, then by full segment
108
+ const staticChild = node.children.get(seg[0]) ?? node.children.get(seg);
109
+ if (staticChild && staticChild.segment === seg) {
110
+ const result = this.matchNode(staticChild, segments, segIdx + 1, params);
111
+ if (result !== null)
112
+ return result;
113
+ }
114
+ // Also check full segment key for collision handling
115
+ for (const [key, child] of node.children) {
116
+ if (key !== seg[0] && child.segment === seg) {
117
+ const result = this.matchNode(child, segments, segIdx + 1, params);
118
+ if (result !== null)
119
+ return result;
120
+ }
121
+ }
122
+ // 2. Try param child (second priority)
123
+ if (node.paramChild) {
124
+ const paramName = node.paramChild.paramName;
125
+ const oldValue = params[paramName];
126
+ params[paramName] = seg;
127
+ const result = this.matchNode(node.paramChild, segments, segIdx + 1, params);
128
+ if (result !== null)
129
+ return result;
130
+ // Backtrack
131
+ if (oldValue !== undefined) {
132
+ params[paramName] = oldValue;
57
133
  }
134
+ else {
135
+ delete params[paramName];
136
+ }
137
+ }
138
+ // 3. Try catch-all child (lowest priority, consumes rest)
139
+ if (node.catchAllChild) {
140
+ const paramName = node.catchAllChild.paramName;
141
+ params[paramName] = segments.slice(segIdx).join('/');
142
+ return node.catchAllChild.routeIndex ?? null;
58
143
  }
59
144
  return null;
60
145
  }