@kuratchi/js 0.0.19 → 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.
- package/README.md +168 -5
- package/dist/compiler/client-module-pipeline.d.ts +8 -0
- package/dist/compiler/client-module-pipeline.js +181 -30
- package/dist/compiler/compiler-shared.d.ts +23 -0
- package/dist/compiler/config-reading.js +27 -1
- package/dist/compiler/convention-discovery.d.ts +2 -0
- package/dist/compiler/convention-discovery.js +16 -0
- package/dist/compiler/durable-object-pipeline.d.ts +1 -0
- package/dist/compiler/durable-object-pipeline.js +459 -119
- package/dist/compiler/index.js +40 -1
- package/dist/compiler/page-route-pipeline.js +31 -2
- package/dist/compiler/parser.d.ts +1 -0
- package/dist/compiler/parser.js +47 -4
- package/dist/compiler/root-layout-pipeline.js +18 -3
- package/dist/compiler/route-pipeline.d.ts +2 -0
- package/dist/compiler/route-pipeline.js +19 -3
- package/dist/compiler/routes-module-feature-blocks.js +143 -17
- package/dist/compiler/routes-module-types.d.ts +1 -0
- package/dist/compiler/template.d.ts +4 -0
- package/dist/compiler/template.js +50 -18
- package/dist/compiler/worker-output-pipeline.js +2 -0
- package/dist/compiler/wrangler-sync.d.ts +3 -0
- package/dist/compiler/wrangler-sync.js +25 -11
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/runtime/context.d.ts +6 -0
- package/dist/runtime/context.js +22 -1
- package/dist/runtime/generated-worker.d.ts +1 -0
- package/dist/runtime/generated-worker.js +11 -7
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/schema.d.ts +49 -0
- package/dist/runtime/schema.js +148 -0
- package/dist/runtime/types.d.ts +2 -0
- package/dist/runtime/validation.d.ts +26 -0
- package/dist/runtime/validation.js +147 -0
- 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
|
|
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(
|
|
168
|
-
return
|
|
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]"
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
142
|
+
console.log(`[kuratchi] Added container "${container.className}" to wrangler config`);
|
|
144
143
|
}
|
|
145
|
-
else if (existing.
|
|
146
|
-
existing.
|
|
144
|
+
else if (existing.name !== name) {
|
|
145
|
+
existing.name = name;
|
|
147
146
|
changed = true;
|
|
148
|
-
console.log(`[kuratchi] Updated container "${container.
|
|
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
|
-
|
|
174
|
-
|
|
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]
|
|
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 */
|
package/dist/runtime/context.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 }), {
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/runtime/index.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -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[];
|