@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.
- package/README.md +19 -19
- package/dist/actions/blueprints/assets.d.ts +3 -3
- package/dist/actions/blueprints/assets.js +3 -3
- package/dist/actions/blueprints/blueprint.d.ts +20 -9
- package/dist/actions/blueprints/blueprint.js +25 -79
- package/dist/actions/blueprints/config.d.ts +1 -0
- package/dist/actions/blueprints/config.js +7 -1
- package/dist/actions/blueprints/pre-deploy.js +3 -3
- package/dist/actions/blueprints/process-assets.js +3 -2
- package/dist/actions/blueprints/stacks.d.ts +9 -3
- package/dist/actions/blueprints/stacks.js +11 -8
- package/dist/actions/blueprints/wait-for-operation.d.ts +1 -0
- package/dist/actions/blueprints/wait-for-operation.js +1 -1
- package/dist/baseCommands.js +3 -3
- package/dist/config.d.ts +1 -2
- package/dist/config.js +1 -2
- package/dist/cores/blueprints/deploy.d.ts +8 -0
- package/dist/cores/blueprints/deploy.js +12 -4
- package/dist/cores/blueprints/destroy.d.ts +14 -1
- package/dist/cores/blueprints/destroy.js +2 -1
- package/dist/cores/blueprints/doctor.js +2 -2
- package/dist/cores/blueprints/init.js +11 -2
- package/dist/cores/functions/test.js +43 -9
- package/dist/functions/artifacts.d.ts +13 -0
- package/dist/functions/artifacts.js +26 -0
- package/dist/functions/build.d.ts +15 -0
- package/dist/functions/build.js +27 -0
- package/dist/functions/contractPlugin.d.ts +7 -0
- package/dist/functions/contractPlugin.js +32 -0
- package/dist/functions/contracts/schema.d.ts +22 -0
- package/dist/functions/contracts/schema.js +99 -0
- package/dist/functions/contracts/validation.d.ts +3 -0
- package/dist/functions/contracts/validation.js +66 -0
- package/dist/functions/contracts.d.ts +20 -0
- package/dist/functions/contracts.js +119 -0
- package/dist/functions/deploy.d.ts +15 -0
- package/dist/functions/deploy.js +60 -0
- package/dist/functions/stack.d.ts +37 -0
- package/dist/functions/stack.js +148 -0
- package/dist/server/app.js +4 -4
- package/dist/server/static/components/app.css +8 -1
- package/dist/utils/blueprints/group-resources.d.ts +2 -1
- package/dist/utils/blueprints/group-resources.js +3 -3
- package/dist/utils/blueprints/resolve-deployed-resources.d.ts +2 -2
- package/dist/utils/blueprints/resolve-deployed-resources.js +4 -4
- package/dist/utils/bundles/prepare-asset.d.ts +3 -3
- package/dist/utils/bundles/prepare-asset.js +8 -7
- package/dist/utils/functions/child-process-wrapper.js +1 -1
- package/dist/utils/functions/child-runtime/durable.js +99 -22
- package/dist/utils/functions/child-runtime/invoke.d.ts +1 -0
- package/dist/utils/functions/child-runtime/invoke.js +26 -9
- package/dist/utils/functions/child-runtime/step-history.d.ts +15 -0
- package/dist/utils/functions/child-runtime/step-history.js +52 -0
- package/dist/utils/functions/extract-durable-config.js +1 -4
- package/dist/utils/functions/invoke/local.js +27 -11
- package/dist/utils/functions/invoke/nested.js +2 -1
- package/dist/utils/functions/invoke/resolve-invoke-type.d.ts +2 -2
- package/dist/utils/functions/invoke/resolve-invoke-type.js +2 -2
- package/dist/utils/functions/transpile/function.d.ts +7 -1
- package/dist/utils/functions/transpile/function.js +15 -9
- package/dist/utils/types.d.ts +24 -13
- package/dist/utils/types.js +1 -1
- package/oclif.manifest.json +1 -1
- package/package.json +24 -3
- package/dist/actions/blueprints/resolve.d.ts +0 -51
- 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
|
|
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
|
|
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
|
-
|
|
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 = [...
|
|
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 =
|
|
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
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
|
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>;
|