@sanity/runtime-cli 17.15.0 → 17.16.0-app-functions.gab0267c

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 (66) hide show
  1. package/README.md +19 -19
  2. package/dist/actions/blueprints/assets.d.ts +3 -3
  3. package/dist/actions/blueprints/assets.js +3 -3
  4. package/dist/actions/blueprints/blueprint.d.ts +20 -9
  5. package/dist/actions/blueprints/blueprint.js +25 -79
  6. package/dist/actions/blueprints/config.d.ts +1 -0
  7. package/dist/actions/blueprints/config.js +7 -1
  8. package/dist/actions/blueprints/pre-deploy.js +3 -3
  9. package/dist/actions/blueprints/process-assets.js +3 -2
  10. package/dist/actions/blueprints/stacks.d.ts +9 -3
  11. package/dist/actions/blueprints/stacks.js +11 -8
  12. package/dist/actions/blueprints/wait-for-operation.d.ts +1 -0
  13. package/dist/actions/blueprints/wait-for-operation.js +1 -1
  14. package/dist/baseCommands.js +3 -3
  15. package/dist/config.d.ts +1 -2
  16. package/dist/config.js +1 -2
  17. package/dist/cores/blueprints/deploy.d.ts +8 -0
  18. package/dist/cores/blueprints/deploy.js +12 -4
  19. package/dist/cores/blueprints/destroy.d.ts +14 -1
  20. package/dist/cores/blueprints/destroy.js +2 -1
  21. package/dist/cores/blueprints/doctor.js +2 -2
  22. package/dist/cores/blueprints/init.js +11 -2
  23. package/dist/cores/functions/test.js +43 -9
  24. package/dist/functions/artifacts.d.ts +13 -0
  25. package/dist/functions/artifacts.js +26 -0
  26. package/dist/functions/build.d.ts +15 -0
  27. package/dist/functions/build.js +27 -0
  28. package/dist/functions/contractPlugin.d.ts +7 -0
  29. package/dist/functions/contractPlugin.js +32 -0
  30. package/dist/functions/contracts/schema.d.ts +22 -0
  31. package/dist/functions/contracts/schema.js +99 -0
  32. package/dist/functions/contracts/validation.d.ts +3 -0
  33. package/dist/functions/contracts/validation.js +66 -0
  34. package/dist/functions/contracts.d.ts +20 -0
  35. package/dist/functions/contracts.js +119 -0
  36. package/dist/functions/deploy.d.ts +15 -0
  37. package/dist/functions/deploy.js +60 -0
  38. package/dist/functions/stack.d.ts +37 -0
  39. package/dist/functions/stack.js +148 -0
  40. package/dist/server/app.js +4 -4
  41. package/dist/server/static/components/app.css +8 -1
  42. package/dist/utils/blueprints/group-resources.d.ts +2 -1
  43. package/dist/utils/blueprints/group-resources.js +3 -3
  44. package/dist/utils/blueprints/resolve-deployed-resources.d.ts +2 -2
  45. package/dist/utils/blueprints/resolve-deployed-resources.js +4 -4
  46. package/dist/utils/bundles/prepare-asset.d.ts +3 -3
  47. package/dist/utils/bundles/prepare-asset.js +8 -7
  48. package/dist/utils/functions/child-process-wrapper.js +1 -1
  49. package/dist/utils/functions/child-runtime/durable.js +99 -22
  50. package/dist/utils/functions/child-runtime/invoke.d.ts +1 -0
  51. package/dist/utils/functions/child-runtime/invoke.js +26 -9
  52. package/dist/utils/functions/child-runtime/step-history.d.ts +15 -0
  53. package/dist/utils/functions/child-runtime/step-history.js +52 -0
  54. package/dist/utils/functions/extract-durable-config.js +1 -4
  55. package/dist/utils/functions/invoke/local.js +27 -11
  56. package/dist/utils/functions/invoke/nested.js +2 -1
  57. package/dist/utils/functions/invoke/resolve-invoke-type.d.ts +2 -2
  58. package/dist/utils/functions/invoke/resolve-invoke-type.js +2 -2
  59. package/dist/utils/functions/transpile/function.d.ts +7 -1
  60. package/dist/utils/functions/transpile/function.js +15 -9
  61. package/dist/utils/types.d.ts +24 -13
  62. package/dist/utils/types.js +1 -1
  63. package/oclif.manifest.json +1 -1
  64. package/package.json +24 -3
  65. package/dist/actions/blueprints/resolve.d.ts +0 -51
  66. package/dist/actions/blueprints/resolve.js +0 -52
package/dist/config.d.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  export declare const BLUEPRINT_CONFIG_VERSION = "v2025-05-08";
2
2
  export declare const BLUEPRINTS_API_VERSION: string;
3
- export declare const BLUEPRINT_CONFIG_DIR = ".sanity";
4
- export declare const BLUEPRINT_CONFIG_FILE = "blueprint.config.json";
3
+ export { STACK_CONFIG_DIR as BLUEPRINT_CONFIG_DIR, STACK_CONFIG_FILE as BLUEPRINT_CONFIG_FILE, } from '@sanity/blueprints/resolve';
5
4
  export declare const RUNTIME_CLI_NAME = "@sanity/runtime-cli";
6
5
  export declare let RUNTIME_CLI_VERSION: string | undefined;
7
6
  export declare const RUNTIME_CLI_USER_AGENT: string;
package/dist/config.js CHANGED
@@ -8,8 +8,7 @@ export const BLUEPRINT_CONFIG_VERSION = 'v2025-05-08';
8
8
  // Default API version for Blueprints service calls.
9
9
  // Overridable globally via env, or per-endpoint via the `version` arg to `blueprintsUrl`.
10
10
  export const BLUEPRINTS_API_VERSION = env.SANITY_BLUEPRINTS_API_VERSION ?? 'v2026-06-15';
11
- export const BLUEPRINT_CONFIG_DIR = '.sanity';
12
- export const BLUEPRINT_CONFIG_FILE = 'blueprint.config.json';
11
+ export { STACK_CONFIG_DIR as BLUEPRINT_CONFIG_DIR, STACK_CONFIG_FILE as BLUEPRINT_CONFIG_FILE, } from '@sanity/blueprints/resolve';
13
12
  export const RUNTIME_CLI_NAME = '@sanity/runtime-cli';
14
13
  export let RUNTIME_CLI_VERSION;
15
14
  try {
@@ -1,3 +1,4 @@
1
+ import type { BlueprintResource } from '@sanity/blueprints';
1
2
  import type { ReadBlueprintResult } from '../../actions/blueprints/blueprint.js';
2
3
  import type { AuthParams, ScopeType, Stack } from '../../utils/types.js';
3
4
  import type { CoreConfig, CoreResult } from '../index.js';
@@ -19,3 +20,10 @@ export interface BlueprintDeployOptions extends CoreConfig {
19
20
  };
20
21
  }
21
22
  export declare function blueprintDeployCore(options: BlueprintDeployOptions): Promise<CoreResult>;
23
+ export interface ResourceDeployOptions extends Omit<BlueprintDeployOptions, 'blueprint'> {
24
+ resources: BlueprintResource[];
25
+ workDir: string;
26
+ fetchFn?: typeof fetch;
27
+ }
28
+ /** Deploy an in-memory resource list using the same pipeline as a Blueprint file. */
29
+ export declare function deployResourcesCore(options: ResourceDeployOptions): Promise<CoreResult>;
@@ -14,16 +14,23 @@ import { styleText } from '../../utils/style-text.js';
14
14
  import { createTracedFetch } from '../../utils/traced-fetch.js';
15
15
  import { getWarnings } from '../../utils/warnings.js';
16
16
  export async function blueprintDeployCore(options) {
17
- const { bin = 'sanity', log: logger, auth, stackId, scopeType, scopeId, deployedStack, blueprint, cliVersion, flags, } = options;
17
+ return deployResourcesCore({
18
+ ...options,
19
+ resources: options.blueprint.resources,
20
+ workDir: dirname(options.blueprint.fileInfo.blueprintFilePath),
21
+ });
22
+ }
23
+ /** Deploy an in-memory resource list using the same pipeline as a Blueprint file. */
24
+ export async function deployResourcesCore(options) {
25
+ const { bin = 'sanity', log: logger, auth, stackId, scopeType, scopeId, deployedStack, workDir: blueprintFileDir, cliVersion, flags, } = options;
18
26
  const { verbose } = flags;
19
27
  const noWait = flags['no-wait'] || false;
20
28
  const stackName = flags['new-stack-name'] ?? deployedStack.name;
21
29
  const installer = flags['fn-installer'] || undefined;
22
- const blueprintFileDir = dirname(blueprint.fileInfo.blueprintFilePath);
23
30
  const workspaceWarnings = getWarnings({ dir: blueprintFileDir });
24
31
  logger(`Deploying "${stackName}" ${niceId(deployedStack.id)}...`);
25
32
  try {
26
- const fetchFn = createTracedFetch(logger);
33
+ const fetchFn = options.fetchFn ?? createTracedFetch(logger);
27
34
  const { ok: checkOk, stack: currentStack } = await getStack({ stackId, auth, fetchFn });
28
35
  if (!checkOk) {
29
36
  return { success: false, error: 'Failed to check current Stack status' };
@@ -39,7 +46,7 @@ export async function blueprintDeployCore(options) {
39
46
  ],
40
47
  };
41
48
  }
42
- const resources = [...blueprint.resources];
49
+ const resources = [...options.resources];
43
50
  let spinner;
44
51
  const preDeployResult = await preDeploy({
45
52
  scopeType,
@@ -143,6 +150,7 @@ export async function blueprintDeployCore(options) {
143
150
  bin,
144
151
  verbose,
145
152
  progressNoun: 'deployment',
153
+ fetchFn,
146
154
  });
147
155
  if (outcome.type === 'completed') {
148
156
  logger(styleText(['bold', 'green'], 'Stack deployment completed!'));
@@ -1,5 +1,5 @@
1
1
  import { type LocatedBlueprintsConfig } from '../../actions/blueprints/config.js';
2
- import type { ScopeType } from '../../utils/types.js';
2
+ import type { AuthParams, ScopeType } from '../../utils/types.js';
3
3
  import type { CoreConfig, CoreResult } from '../index.js';
4
4
  export interface BlueprintDestroyOptions extends CoreConfig {
5
5
  token: string;
@@ -20,3 +20,16 @@ export interface BlueprintDestroyOptions extends CoreConfig {
20
20
  };
21
21
  }
22
22
  export declare function blueprintDestroyCore(options: BlueprintDestroyOptions): Promise<CoreResult>;
23
+ type WaitForDestructionOptions = {
24
+ stackId: string;
25
+ stackName: string;
26
+ operationId: string;
27
+ requestId?: string;
28
+ auth: AuthParams;
29
+ log: CoreConfig['log'];
30
+ bin: string;
31
+ verbose?: boolean;
32
+ fetchFn?: typeof fetch;
33
+ };
34
+ export declare function waitForDestruction(options: WaitForDestructionOptions): Promise<CoreResult>;
35
+ export {};
@@ -150,7 +150,7 @@ function afterDestroyAccepted(options) {
150
150
  }
151
151
  return waitForDestruction(options);
152
152
  }
153
- async function waitForDestruction(options) {
153
+ export async function waitForDestruction(options) {
154
154
  const { stackId, stackName, operationId, requestId, auth, log, bin, verbose = false } = options;
155
155
  log(styleText('dim', 'Stack destruction progress:'));
156
156
  log('');
@@ -163,6 +163,7 @@ async function waitForDestruction(options) {
163
163
  verbose,
164
164
  includeDestroyed: true,
165
165
  progressNoun: 'destruction',
166
+ fetchFn: options.fetchFn,
166
167
  });
167
168
  if (outcome.type === 'completed') {
168
169
  log(styleText(['bold', 'magenta'], 'Stack destruction completed!'));
@@ -1,11 +1,11 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import { arch, cwd, env, version as nodeVersion, platform } from 'node:process';
4
+ import { resolveStackConfig } from '@sanity/blueprints/resolve';
4
5
  import { formatDateTime } from '@sanity/cli-core/dates';
5
6
  import * as resolve from 'empathic/resolve';
6
7
  import { loadAndParseBlueprint, } from '../../actions/blueprints/blueprint.js';
7
8
  import { readConfigFile } from '../../actions/blueprints/config.js';
8
- import { resolveIds } from '../../actions/blueprints/resolve.js';
9
9
  import { getStack } from '../../actions/blueprints/stacks.js';
10
10
  import config, { RUNTIME_CLI_VERSION } from '../../config.js';
11
11
  import { presentBlueprintParserErrors } from '../../utils/display/errors.js';
@@ -92,7 +92,7 @@ export async function blueprintDoctorCore(options) {
92
92
  validateResources: options.validateResources || false,
93
93
  });
94
94
  const blueprintConfig = readConfigFile(blueprint.fileInfo.blueprintFilePath);
95
- const resolved = resolveIds({
95
+ const resolved = resolveStackConfig({
96
96
  module: blueprint.module,
97
97
  config: blueprintConfig,
98
98
  });
@@ -125,6 +125,16 @@ async function handleExistingBlueprint(options) {
125
125
  error: 'Flag --example cannot be used with an existing Blueprint.',
126
126
  };
127
127
  }
128
+ const existingBlueprint = await readLocalBlueprint(log, { resources: validateResources || false }, existingBlueprintFile.blueprintFilePath);
129
+ const hasStack = Boolean(existingBlueprint.blueprintConfig?.stackId);
130
+ if (hasStack) {
131
+ const overwrite = await confirm({
132
+ message: 'This Blueprint has an existing Stack. Are you sure you want to re-initialize?',
133
+ default: true,
134
+ });
135
+ if (!overwrite)
136
+ return { success: false, error: 'Initialization cancelled.' };
137
+ }
128
138
  // --stack-name with a scope flag means create a new stack before configuring
129
139
  let resolvedStackId = flagStackId;
130
140
  if (!resolvedStackId && flagStackName) {
@@ -142,7 +152,7 @@ async function handleExistingBlueprint(options) {
142
152
  }
143
153
  // unknown intent, prompt for confirmation
144
154
  const flagProvidedConfig = [flagProjectId, flagOrganizationId, flagStackId].some(Boolean);
145
- if (!flagProvidedConfig && !flagStackName) {
155
+ if (!flagProvidedConfig && !hasStack && !flagStackName) {
146
156
  const overwrite = await confirm({
147
157
  message: "This Blueprint doesn't have a deployment Stack yet. Configure one now?",
148
158
  default: true,
@@ -150,7 +160,6 @@ async function handleExistingBlueprint(options) {
150
160
  if (!overwrite)
151
161
  return { success: false, error: 'Initialization cancelled.' };
152
162
  }
153
- const existingBlueprint = await readLocalBlueprint(log, { resources: validateResources || false }, existingBlueprintFile.blueprintFilePath);
154
163
  return blueprintConfigCore({
155
164
  blueprint: existingBlueprint,
156
165
  bin,
@@ -1,7 +1,7 @@
1
1
  import { dirname } from 'node:path';
2
2
  import { testAction } from '../../actions/functions/test.js';
3
3
  import config from '../../config.js';
4
- import { STEP_PHASE_GLYPH } from '../../constants.js';
4
+ import { SANITY_FUNCTION_DURABLE, STEP_PHASE_GLYPH } from '../../constants.js';
5
5
  import { resolveDeployedResources } from '../../utils/blueprints/resolve-deployed-resources.js';
6
6
  import { createDurableJsonCollector } from '../../utils/functions/durable-logs.js';
7
7
  import { findFunctionInResources, getFunctionNames } from '../../utils/functions/find.js';
@@ -55,7 +55,7 @@ export async function functionTestCore(options) {
55
55
  apiHost: config.apiUrl,
56
56
  token: config.token || undefined,
57
57
  withUserToken,
58
- resources: resolvedResources,
58
+ resolvedResources,
59
59
  blueprintFileDir: dirname(blueprint.fileInfo.blueprintFilePath),
60
60
  log,
61
61
  };
@@ -88,15 +88,40 @@ export async function functionTestCore(options) {
88
88
  * surfaced after the spinner resolves, including failed ones — a downstream
89
89
  * error is fire-and-forget (it doesn't reject the caller) but is still shown.
90
90
  */
91
- const runNested = async (nestedName, nestedPayload, ctx, depth, callStack) => {
91
+ const runNested = async (nestedName, nestedPayload, ctx, depth, callStack, metadata) => {
92
92
  const order = ++nestedOrder;
93
93
  const nestedResource = findFunctionInResources(blueprint.resources, nestedName);
94
- const result = await runFunction(nestedResource, { payload: nestedPayload, event: 'nested' }, { ...ctx, resources: resolvedResources }, { depth, callStack, onNestedInvoke: runNested });
95
- nestedRuns.push({ order, name: nestedName, logs: result.logs, error: result.error });
96
- return result;
94
+ const isLiveDurable = metadata?.origin === 'step.delegate' && nestedResource.type === SANITY_FUNCTION_DURABLE;
95
+ if (isLiveDurable) {
96
+ log(`↳ BEGIN: invoke('${nestedName}')`);
97
+ }
98
+ try {
99
+ const result = await runFunction(nestedResource, { payload: nestedPayload, event: 'nested' }, { ...ctx, resources: resolvedResources }, { depth, callStack, onNestedInvoke: runNested, onStepEvent });
100
+ if (result.error && isLiveDurable) {
101
+ log(result.error.stack || result.error.message);
102
+ }
103
+ nestedRuns.push({
104
+ order,
105
+ name: nestedName,
106
+ logs: result.logs,
107
+ error: result.error,
108
+ isLiveDurable,
109
+ });
110
+ return result;
111
+ }
112
+ finally {
113
+ if (isLiveDurable) {
114
+ log(`↳ END: invoke('${nestedName}')`);
115
+ }
116
+ }
97
117
  };
98
118
  const logNestedRuns = () => {
99
- for (const run of [...nestedRuns].sort((a, b) => a.order - b.order)) {
119
+ // Remove buffered logs for durable runs
120
+ const runs = plan.isDurable ? nestedRuns.splice(0) : [...nestedRuns];
121
+ for (const run of runs.sort((a, b) => a.order - b.order)) {
122
+ // Skip if is Durable so log ordering is retained
123
+ if (run.isLiveDurable)
124
+ continue;
100
125
  log(`↳ BEGIN: invoke('${run.name}')`);
101
126
  // Show any logs received
102
127
  if (run.logs?.trim()) {
@@ -132,10 +157,19 @@ export async function functionTestCore(options) {
132
157
  if (event.kind === 'delegate') {
133
158
  logNestedRuns();
134
159
  }
135
- const glyph = STEP_PHASE_GLYPH[event.phase] ?? '•';
160
+ const willRetry = event.phase === 'error' && typeof event.retryDelayMs === 'number';
161
+ const glyph = willRetry ? '⟳' : (STEP_PHASE_GLYPH[event.phase] ?? '•');
136
162
  const duration = typeof event.durationMs === 'number' ? ` (${Math.round(event.durationMs)}ms)` : '';
163
+ const retriesPerformed = (event.attempt ?? 1) - 1;
164
+ let retryNote = '';
165
+ if (willRetry) {
166
+ retryNote = ` retry ${event.attempt} of ${event.maxAttempts}`;
167
+ }
168
+ else if (event.phase === 'error' && retriesPerformed > 0) {
169
+ retryNote = ` failed after ${retriesPerformed} retries`;
170
+ }
137
171
  const detail = event.phase === 'error' && event.error ? ` ${event.error.message}` : '';
138
- lines.push(` ${glyph} ${event.name} [${event.kind}]${duration}${detail}`);
172
+ lines.push(` ${glyph} ${event.name} [${event.kind}]${duration}${detail}${retryNote}`);
139
173
  }
140
174
  return lines;
141
175
  }
@@ -0,0 +1,13 @@
1
+ export declare const functionOutputFileName = "index.js";
2
+ /** Metadata for a compiled function, resolved without loading its handler. */
3
+ export interface FunctionBuild {
4
+ name: string;
5
+ src: string;
6
+ entryPoint: string;
7
+ }
8
+ /** Validate artifacts produced by buildFunction before packaging or deployment. */
9
+ export declare function readFunctionBuild(options: {
10
+ name: string;
11
+ src: string;
12
+ workDir: string;
13
+ }): Promise<FunctionBuild>;
@@ -0,0 +1,26 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { findFunctionEntryPoint } from '../utils/functions/find-entry-point.js';
4
+ export const functionOutputFileName = 'index.js';
5
+ /** Validate artifacts produced by buildFunction before packaging or deployment. */
6
+ export async function readFunctionBuild(options) {
7
+ const { name, workDir } = options;
8
+ const src = path.resolve(workDir, options.src);
9
+ try {
10
+ const pkg = JSON.parse(await readFile(path.join(src, 'package.json'), 'utf8'));
11
+ if (!pkg ||
12
+ typeof pkg !== 'object' ||
13
+ !('main' in pkg) ||
14
+ pkg.main !== functionOutputFileName ||
15
+ !('type' in pkg) ||
16
+ pkg.type !== 'module')
17
+ throw new Error('The function package metadata does not match its build format.');
18
+ const entryPoint = await findFunctionEntryPoint(src, name);
19
+ if (entryPoint !== path.join(src, functionOutputFileName))
20
+ throw new Error(`The compiled entry ${functionOutputFileName} is missing.`);
21
+ return { name, src, entryPoint };
22
+ }
23
+ catch (error) {
24
+ throw new Error(`Function "${name}" is not built. Run sanity build first.`, { cause: error });
25
+ }
26
+ }
@@ -0,0 +1,15 @@
1
+ import { type FunctionContract } from './contracts.js';
2
+ export interface BuildFunctionOptions {
3
+ name: string;
4
+ contract?: FunctionContract;
5
+ /** Function directory or entry file, relative to workDir. */
6
+ src: string;
7
+ workDir: string;
8
+ /** Build output directory, relative to workDir. Its contents are replaced. */
9
+ outDir: string;
10
+ }
11
+ /** Build a standalone ESM function bundle without a blueprint, stack, or ZIP. */
12
+ export declare function buildFunction({ name, src, workDir, outDir, contract, }: BuildFunctionOptions): Promise<{
13
+ outputDir: string;
14
+ }>;
15
+ export { type FunctionBuild, readFunctionBuild } from './artifacts.js';
@@ -0,0 +1,27 @@
1
+ import path from 'node:path';
2
+ import { findFunctionEntryPoint } from '../utils/functions/find-entry-point.js';
3
+ import { transpileFunction } from '../utils/functions/transpile/function.js';
4
+ import { functionOutputFileName } from './artifacts.js';
5
+ import { functionContractPlugin } from './contractPlugin.js';
6
+ import { generateFunctionContracts } from './contracts.js';
7
+ /** Build a standalone ESM function bundle without a blueprint, stack, or ZIP. */
8
+ export async function buildFunction({ name, src, workDir, outDir, contract, }) {
9
+ const functionPath = path.resolve(workDir, src);
10
+ const entryPoint = await findFunctionEntryPoint(functionPath, name);
11
+ const resolvedContract = contract ?? (await generateFunctionContracts({ workDir, functions: [{ name, src }] }))[0];
12
+ if (resolvedContract.entryPoint !== entryPoint || resolvedContract.name !== name)
13
+ throw new Error(`Function contract does not match "${name}".`);
14
+ const validation = functionContractPlugin(resolvedContract);
15
+ const result = await transpileFunction({
16
+ resource: { name, src, type: 'sanity.function.pubsub' },
17
+ functionPath,
18
+ entryPoint: validation.entryPoint,
19
+ plugins: [validation.plugin],
20
+ workDir,
21
+ outDir: path.resolve(workDir, outDir),
22
+ outputFileName: functionOutputFileName,
23
+ bundle: true,
24
+ });
25
+ return { outputDir: result.outputDir };
26
+ }
27
+ export { readFunctionBuild } from './artifacts.js';
@@ -0,0 +1,7 @@
1
+ import type { Plugin } from 'vite';
2
+ import type { FunctionContract } from './contracts.js';
3
+ /** Build a validating PubSub entry around the original handler. */
4
+ export declare function functionContractPlugin(contract: FunctionContract): {
5
+ entryPoint: string;
6
+ plugin: Plugin;
7
+ };
@@ -0,0 +1,32 @@
1
+ import { jsonGuard, validatorSource } from './contracts/validation.js';
2
+ /** Build a validating PubSub entry around the original handler. */
3
+ export function functionContractPlugin(contract) {
4
+ const entryPoint = `virtual:sanity-function-contract:${contract.name}`;
5
+ const resolvedId = `\0${entryPoint}`;
6
+ const source = `import {handler as implementation} from ${JSON.stringify(contract.entryPoint.replaceAll('\\', '/'))}
7
+ ${jsonGuard}
8
+ const validateInput = ${validatorSource(contract.input)}
9
+ const validateOutput = ${validatorSource(contract.output)}
10
+ export async function handler(envelope) {
11
+ let input = envelope?.event?.data
12
+ ${contract.input.kind === 'void' ? "if (input !== null && typeof input === 'object' && !Array.isArray(input) && Object.keys(input).length === 0) input = undefined" : ''}
13
+ if (!validateInput(input)) throw new TypeError(${JSON.stringify(`Invalid input for function "${contract.name}". Match its declared input type.`)})
14
+ const result = await implementation(envelope)
15
+ if (!validateOutput(result)) throw new TypeError(${JSON.stringify(`Function "${contract.name}" returned data that does not match its declared return type.`)})
16
+ return result
17
+ }`;
18
+ return {
19
+ entryPoint,
20
+ plugin: {
21
+ name: 'sanity/function-contract',
22
+ resolveId(id) {
23
+ if (id === entryPoint)
24
+ return resolvedId;
25
+ },
26
+ load(id) {
27
+ if (id === resolvedId)
28
+ return source;
29
+ },
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,22 @@
1
+ import ts from 'typescript-compiler';
2
+ export type ContractSchema = {
3
+ kind: 'string' | 'number' | 'boolean' | 'null' | 'void' | 'never';
4
+ } | {
5
+ kind: 'literal';
6
+ value: string | number | boolean;
7
+ } | {
8
+ kind: 'array';
9
+ items: ContractSchema;
10
+ } | {
11
+ kind: 'union';
12
+ variants: ContractSchema[];
13
+ } | {
14
+ kind: 'object';
15
+ properties: Record<string, {
16
+ optional: boolean;
17
+ schema: ContractSchema;
18
+ }>;
19
+ additional?: ContractSchema;
20
+ };
21
+ export declare function schemaFromType(checker: ts.TypeChecker, type: ts.Type, location: string, allowVoid?: boolean, parents?: Set<ts.Type>): ContractSchema;
22
+ export declare function contractType(schema: ContractSchema): string;
@@ -0,0 +1,99 @@
1
+ import ts from 'typescript-compiler';
2
+ export function schemaFromType(checker, type, location, allowVoid = false, parents = new Set()) {
3
+ const fail = (reason) => {
4
+ throw new Error(`${location}: ${reason} (${checker.typeToString(type)}). Use a concrete JSON-compatible type.`);
5
+ };
6
+ const flags = type.flags;
7
+ if (flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.TypeParameter))
8
+ return fail('Unresolved or untyped contract');
9
+ if (flags & ts.TypeFlags.Never)
10
+ return { kind: 'never' };
11
+ if (flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined)) {
12
+ if (!allowVoid)
13
+ return fail('Undefined is only supported for optional object fields or a void result');
14
+ return { kind: 'void' };
15
+ }
16
+ if (flags & ts.TypeFlags.Null)
17
+ return { kind: 'null' };
18
+ if (flags & ts.TypeFlags.StringLiteral)
19
+ return { kind: 'literal', value: type.value };
20
+ if (flags & ts.TypeFlags.NumberLiteral)
21
+ return { kind: 'literal', value: type.value };
22
+ if (flags & ts.TypeFlags.BooleanLiteral)
23
+ return { kind: 'literal', value: checker.typeToString(type) === 'true' };
24
+ if (flags & ts.TypeFlags.String)
25
+ return { kind: 'string' };
26
+ if (flags & ts.TypeFlags.Number)
27
+ return { kind: 'number' };
28
+ if (flags & ts.TypeFlags.Boolean)
29
+ return { kind: 'boolean' };
30
+ if (parents.has(type) || parents.size > 64)
31
+ return fail('Recursive or excessively nested contract');
32
+ const next = new Set(parents).add(type);
33
+ if (type.isUnion())
34
+ return {
35
+ kind: 'union',
36
+ variants: type.types.map((item) => schemaFromType(checker, item, location, allowVoid, next)),
37
+ };
38
+ if (checker.isTupleType(type))
39
+ return fail('Tuple contracts are not supported; use an array');
40
+ if (checker.isArrayType(type)) {
41
+ const element = checker.getTypeArguments(type)[0];
42
+ return { kind: 'array', items: schemaFromType(checker, element, `${location}[]`, false, next) };
43
+ }
44
+ if (!(flags & (ts.TypeFlags.Object | ts.TypeFlags.Intersection)))
45
+ return fail('Unsupported contract');
46
+ if (type.getCallSignatures().length || type.getConstructSignatures().length)
47
+ return fail('Functions and constructors cannot cross the function boundary');
48
+ if (type
49
+ .getSymbol()
50
+ ?.getDeclarations()
51
+ ?.some((declaration) => ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration)))
52
+ return fail('Class instances cannot cross the function boundary');
53
+ if (checker.getIndexTypeOfType(type, ts.IndexKind.Number))
54
+ return fail('Numeric index signatures are not supported; use an array or string-keyed record');
55
+ const properties = Object.create(null);
56
+ for (const property of checker.getPropertiesOfType(type)) {
57
+ const name = property.getName();
58
+ if (name.startsWith('__@'))
59
+ return fail('Symbol properties cannot cross the function boundary');
60
+ const declaration = property.valueDeclaration ?? property.declarations?.[0];
61
+ if (!declaration)
62
+ return fail(`Cannot resolve property ${name}`);
63
+ const propertyType = checker.getTypeOfSymbolAtLocation(property, declaration);
64
+ const optional = !!(property.flags & ts.SymbolFlags.Optional);
65
+ const members = optional
66
+ ? (propertyType.isUnion() ? propertyType.types : [propertyType]).filter((member) => !(member.flags & ts.TypeFlags.Undefined))
67
+ : [propertyType];
68
+ const schemas = members.map((member) => schemaFromType(checker, member, `${location}.${name}`, false, next));
69
+ properties[name] = {
70
+ optional,
71
+ schema: schemas.length === 0
72
+ ? { kind: 'never' }
73
+ : schemas.length === 1
74
+ ? schemas[0]
75
+ : { kind: 'union', variants: schemas },
76
+ };
77
+ }
78
+ const indexType = checker.getIndexTypeOfType(type, ts.IndexKind.String);
79
+ const additional = indexType
80
+ ? schemaFromType(checker, indexType, `${location}[key]`, false, next)
81
+ : undefined;
82
+ return { kind: 'object', properties, ...(additional ? { additional } : {}) };
83
+ }
84
+ export function contractType(schema) {
85
+ switch (schema.kind) {
86
+ case 'literal':
87
+ return JSON.stringify(schema.value);
88
+ case 'array':
89
+ return `(${contractType(schema.items)})[]`;
90
+ case 'union':
91
+ return schema.variants.map(contractType).join(' | ');
92
+ case 'object':
93
+ return `{${Object.entries(schema.properties)
94
+ .map(([name, property]) => `${JSON.stringify(name)}${property.optional ? '?' : ''}: ${contractType(property.schema)};`)
95
+ .join(' ')}${schema.additional ? `[key: string]: ${contractType(schema.additional)};` : ''}}`;
96
+ default:
97
+ return schema.kind;
98
+ }
99
+ }
@@ -0,0 +1,3 @@
1
+ import type { ContractSchema } from './schema.js';
2
+ export declare const jsonGuard = "function isJson(value, seen = new WeakSet(), depth = 0) {\n if (depth > 100) return false\n if (value === undefined || value === null) return true\n if (typeof value === 'string' || typeof value === 'boolean') return true\n if (typeof value === 'number') return Number.isFinite(value)\n if (typeof value !== 'object' || seen.has(value) || Object.getOwnPropertySymbols(value).length) return false\n seen.add(value)\n try {\n if (Array.isArray(value)) {\n if (Object.keys(value).length !== value.length) return false\n for (let index = 0; index < value.length; index++) {\n if (!Object.hasOwn(value, index) || value[index] === undefined || !isJson(value[index], seen, depth + 1)) return false\n }\n return true\n }\n const prototype = Object.getPrototypeOf(value)\n return (prototype === Object.prototype || prototype === null) && Object.values(value).every(item => isJson(item, seen, depth + 1))\n } finally { seen.delete(value) }\n}";
3
+ export declare function validatorSource(schema: ContractSchema): string;
@@ -0,0 +1,66 @@
1
+ // Self-contained code emitted into browser contracts and function bundles.
2
+ export const jsonGuard = `function isJson(value, seen = new WeakSet(), depth = 0) {
3
+ if (depth > 100) return false
4
+ if (value === undefined || value === null) return true
5
+ if (typeof value === 'string' || typeof value === 'boolean') return true
6
+ if (typeof value === 'number') return Number.isFinite(value)
7
+ if (typeof value !== 'object' || seen.has(value) || Object.getOwnPropertySymbols(value).length) return false
8
+ seen.add(value)
9
+ try {
10
+ if (Array.isArray(value)) {
11
+ if (Object.keys(value).length !== value.length) return false
12
+ for (let index = 0; index < value.length; index++) {
13
+ if (!Object.hasOwn(value, index) || value[index] === undefined || !isJson(value[index], seen, depth + 1)) return false
14
+ }
15
+ return true
16
+ }
17
+ const prototype = Object.getPrototypeOf(value)
18
+ return (prototype === Object.prototype || prototype === null) && Object.values(value).every(item => isJson(item, seen, depth + 1))
19
+ } finally { seen.delete(value) }
20
+ }`;
21
+ export function validatorSource(schema) {
22
+ return `(value) => isJson(value) && (${validationExpression(schema, 'value')})`;
23
+ }
24
+ function validationExpression(schema, value, depth = 0) {
25
+ switch (schema.kind) {
26
+ case 'void':
27
+ return `${value} === undefined`;
28
+ case 'never':
29
+ return 'false';
30
+ case 'null':
31
+ return `${value} === null`;
32
+ case 'literal':
33
+ return `${value} === ${JSON.stringify(schema.value)}`;
34
+ case 'number':
35
+ return `(typeof ${value} === 'number' && Number.isFinite(${value}))`;
36
+ case 'string':
37
+ case 'boolean':
38
+ return `typeof ${value} === '${schema.kind}'`;
39
+ case 'union':
40
+ return `(${schema.variants.map((item) => `(${validationExpression(item, value, depth + 1)})`).join(' || ')})`;
41
+ case 'array': {
42
+ const item = `item${depth}`;
43
+ return `(Array.isArray(${value}) && ${value}.every(${item} => ${validationExpression(schema.items, item, depth + 1)}))`;
44
+ }
45
+ case 'object': {
46
+ const checks = [
47
+ `${value} !== null`,
48
+ `typeof ${value} === 'object'`,
49
+ `!Array.isArray(${value})`,
50
+ ];
51
+ for (const [name, property] of Object.entries(schema.properties)) {
52
+ const member = `${value}[${JSON.stringify(name)}]`;
53
+ const present = `Object.hasOwn(${value}, ${JSON.stringify(name)})`;
54
+ const check = validationExpression(property.schema, member, depth + 1);
55
+ checks.push(property.optional
56
+ ? `(!${present} || ${member} === undefined || (${check}))`
57
+ : `(${present} && (${check}))`);
58
+ }
59
+ if (schema.additional) {
60
+ const key = `key${depth}`;
61
+ checks.push(`Object.keys(${value}).every(${key} => ${JSON.stringify(Object.keys(schema.properties))}.includes(${key}) || (${validationExpression(schema.additional, `${value}[${key}]`, depth + 1)}))`);
62
+ }
63
+ return `(${checks.join(' && ')})`;
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,20 @@
1
+ import { type ContractSchema } from './contracts/schema.js';
2
+ export interface FunctionContract {
3
+ name: string;
4
+ src: string;
5
+ entryPoint: string;
6
+ input: ContractSchema;
7
+ output: ContractSchema;
8
+ }
9
+ /** Discover input and output types without loading or executing function code. */
10
+ export declare function generateFunctionContracts(options: {
11
+ workDir: string;
12
+ functions: {
13
+ name: string;
14
+ src: string;
15
+ }[];
16
+ }): Promise<FunctionContract[]>;
17
+ /** Check app consumers after regenerating their function registry. */
18
+ export declare function checkFunctionTypes(workDir: string): void;
19
+ /** Emit a standalone declaration registry and browser-safe validators. */
20
+ export declare function writeFunctionContracts(workDir: string, contracts: FunctionContract[]): Promise<void>;