@sanity/runtime-cli 17.11.1 → 17.12.1

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 (49) hide show
  1. package/README.md +19 -19
  2. package/dist/actions/blueprints/assets.d.ts +21 -20
  3. package/dist/actions/blueprints/assets.js +17 -87
  4. package/dist/actions/blueprints/logs-polling.d.ts +3 -5
  5. package/dist/actions/blueprints/logs-polling.js +4 -9
  6. package/dist/actions/blueprints/logs.d.ts +1 -2
  7. package/dist/actions/blueprints/logs.js +1 -3
  8. package/dist/actions/blueprints/operations-state.d.ts +10 -0
  9. package/dist/actions/blueprints/operations-state.js +42 -0
  10. package/dist/actions/blueprints/operations.d.ts +2 -9
  11. package/dist/actions/blueprints/operations.js +1 -15
  12. package/dist/actions/blueprints/pre-deploy.d.ts +14 -9
  13. package/dist/actions/blueprints/pre-deploy.js +83 -26
  14. package/dist/actions/blueprints/process-assets.d.ts +12 -11
  15. package/dist/actions/blueprints/process-assets.js +8 -7
  16. package/dist/actions/blueprints/stacks.d.ts +4 -4
  17. package/dist/actions/blueprints/stacks.js +2 -4
  18. package/dist/actions/blueprints/wait-for-operation.d.ts +1 -10
  19. package/dist/actions/blueprints/wait-for-operation.js +56 -101
  20. package/dist/baseCommands.js +6 -1
  21. package/dist/cores/blueprints/deploy.js +28 -6
  22. package/dist/cores/blueprints/destroy.js +4 -2
  23. package/dist/cores/blueprints/doctor.js +1 -1
  24. package/dist/cores/blueprints/init.js +3 -1
  25. package/dist/cores/blueprints/logs.js +11 -5
  26. package/dist/cores/functions/build.js +4 -5
  27. package/dist/cores/functions/test.js +33 -21
  28. package/dist/cores/index.js +3 -1
  29. package/dist/server/handlers/invoke.js +1 -1
  30. package/dist/utils/blueprints/resolve-deployed-resources.d.ts +2 -3
  31. package/dist/utils/blueprints/resolve-deployed-resources.js +2 -2
  32. package/dist/utils/blueprints/wait-for.d.ts +60 -0
  33. package/dist/utils/blueprints/wait-for.js +115 -0
  34. package/dist/utils/functions/child-process-wrapper.js +1 -1
  35. package/dist/utils/functions/child-runtime/durable.js +8 -0
  36. package/dist/utils/functions/invoke/assert-console.d.ts +5 -0
  37. package/dist/utils/functions/invoke/assert-console.js +60 -0
  38. package/dist/utils/functions/invoke/local.js +9 -0
  39. package/dist/utils/functions/is-folder-larger-than.d.ts +5 -0
  40. package/dist/utils/functions/is-folder-larger-than.js +38 -0
  41. package/dist/utils/functions/prepare-asset.d.ts +2 -0
  42. package/dist/utils/functions/prepare-asset.js +43 -6
  43. package/dist/utils/studios/prepare-asset.js +5 -3
  44. package/dist/utils/types.d.ts +19 -5
  45. package/dist/utils/types.js +0 -3
  46. package/oclif.manifest.json +1 -1
  47. package/package.json +2 -2
  48. package/dist/utils/functions/get-folder-size.d.ts +0 -5
  49. package/dist/utils/functions/get-folder-size.js +0 -24
@@ -4,6 +4,7 @@ import { formatTitle } from '../../utils/display/blueprints-formatting.js';
4
4
  import { formatLogEntry, formatLogs } from '../../utils/display/logs-formatting.js';
5
5
  import { niceId } from '../../utils/display/presenters.js';
6
6
  import { styleText } from '../../utils/style-text.js';
7
+ import { createTracedFetch } from '../../utils/traced-fetch.js';
7
8
  /** Number of recent logs to show before entering watch mode */
8
9
  const WATCH_PREFETCH_LIMIT = 10;
9
10
  export async function blueprintLogsCore(options) {
@@ -11,8 +12,9 @@ export async function blueprintLogsCore(options) {
11
12
  const { watch = false, verbose = false, limit, since, before } = flags;
12
13
  const spinner = log.ora(`Fetching recent logs for Stack deployment ${niceId(stackId)}`).start();
13
14
  try {
15
+ const fetchFn = createTracedFetch(log);
14
16
  if (watch) {
15
- const { ok, logs, error } = await getLogs({ stackId, limit: WATCH_PREFETCH_LIMIT }, auth, log);
17
+ const { ok, logs, error } = await getLogs({ stackId, limit: WATCH_PREFETCH_LIMIT }, auth, fetchFn);
16
18
  if (!ok) {
17
19
  spinner.fail(`${styleText('red', 'Failed')} to retrieve logs`);
18
20
  log.error(`Error: ${error || 'Unknown error'}`);
@@ -38,9 +40,13 @@ export async function blueprintLogsCore(options) {
38
40
  stackId,
39
41
  knownIds: logs.map((l) => l.id),
40
42
  auth,
41
- log,
42
- showBanner: true,
43
- verbose,
43
+ fetchFn,
44
+ onShowBanner() {
45
+ log(`Watching logs... ${styleText('bold', 'ctrl+c')} to cancel`);
46
+ },
47
+ onLogEntry(logEntry, previousLog) {
48
+ log(formatLogEntry(logEntry, verbose, previousLog));
49
+ },
44
50
  });
45
51
  // Return a never-resolving promise so the polling loop keeps running
46
52
  return {
@@ -49,7 +55,7 @@ export async function blueprintLogsCore(options) {
49
55
  };
50
56
  }
51
57
  // One-shot fetch (no watch)
52
- const { ok, logs, error, hasMore } = await getLogs({ stackId, limit, before, after: since }, auth, log);
58
+ const { ok, logs, error, hasMore } = await getLogs({ stackId, limit, before, after: since }, auth, fetchFn);
53
59
  if (!ok) {
54
60
  spinner.fail(`${styleText('red', 'Failed')} to retrieve Stack deployment logs`);
55
61
  log.error(`Error: ${error || 'Unknown error'}`);
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { pathToZip } from '../../actions/blueprints/assets.js';
3
+ import { CONVERT_BYTES_TO_MB } from '../../constants.js';
4
4
  import { findFunctionInResources } from '../../utils/functions/find.js';
5
5
  import { prepareAsset } from '../../utils/functions/prepare-asset.js';
6
6
  import { isLocalFunctionResource } from '../../utils/types.js';
@@ -31,14 +31,13 @@ export async function functionBuildCore(options) {
31
31
  errors.push({ name: resource.name, error: prepResult.error ?? 'Unknown error' });
32
32
  continue;
33
33
  }
34
- const zip = await pathToZip(prepResult.outputPath);
35
- const buffer = zip.toBuffer();
34
+ const buffer = prepResult.outputBuffer;
36
35
  const zipPath = path.join(outDir, `${resource.name}.zip`);
37
- await fs.promises.writeFile(zipPath, buffer);
36
+ await fs.promises.writeFile(zipPath, prepResult.outputBuffer);
38
37
  if (prepResult.cleanup) {
39
38
  await prepResult.cleanup();
40
39
  }
41
- const sizeMb = (buffer.length / 1_048_576).toFixed(1);
40
+ const sizeMb = (buffer.length / CONVERT_BYTES_TO_MB).toFixed(1);
42
41
  spinner.succeed(`${resource.name}.zip (${sizeMb} MB) → ${zipPath}`);
43
42
  built.push({ name: resource.name, size: buffer.length, path: zipPath });
44
43
  }
@@ -7,27 +7,8 @@ import { createDurableJsonCollector } from '../../utils/functions/durable-logs.j
7
7
  import { findFunctionInResources, getFunctionNames } from '../../utils/functions/find.js';
8
8
  import { runFunction } from '../../utils/functions/invoke/nested.js';
9
9
  import { buildContext, buildInvokeOptions, collectInvokeInput, defaultInvocationDeps, resolveInvocationPlan, } from '../../utils/functions/invoke/resolve-invoke-type.js';
10
+ import { createTracedFetch } from '../../utils/traced-fetch.js';
10
11
  import { isEventType, } from '../../utils/types.js';
11
- /**
12
- * CLI lines for a durable run, in emission order; bare `start` events dropped.
13
- * @param events
14
- */
15
- function renderStepTimeline(events) {
16
- const lines = [];
17
- for (const event of events) {
18
- if (event.kind === 'log') {
19
- lines.push(`${event.timestamp} ${event.level.toUpperCase()} ${event.message}`);
20
- continue;
21
- }
22
- if (event.phase === 'start')
23
- continue;
24
- const glyph = STEP_PHASE_GLYPH[event.phase] ?? '•';
25
- const duration = typeof event.durationMs === 'number' ? ` (${Math.round(event.durationMs)}ms)` : '';
26
- const detail = event.phase === 'error' && event.error ? ` ${event.error.message}` : '';
27
- lines.push(` ${glyph} ${event.name} [${event.kind}]${duration}${detail}`);
28
- }
29
- return lines;
30
- }
31
12
  export async function functionTestCore(options) {
32
13
  const { blueprint, log, error, args, flags, helpText } = options;
33
14
  const { name: fnName } = args;
@@ -56,6 +37,7 @@ export async function functionTestCore(options) {
56
37
  };
57
38
  }
58
39
  try {
40
+ const fetchFn = createTracedFetch(log);
59
41
  const resource = findFunctionInResources(blueprint.resources, fnName); // throws if not found
60
42
  const resolvedResources = await resolveDeployedResources({
61
43
  resources: blueprint.resources,
@@ -63,7 +45,7 @@ export async function functionTestCore(options) {
63
45
  auth: config.token && blueprint.scopeType && blueprint.scopeId
64
46
  ? { token: config.token, scopeType: blueprint.scopeType, scopeId: blueprint.scopeId }
65
47
  : undefined,
66
- logger: log,
48
+ fetchFn,
67
49
  });
68
50
  const input = {
69
51
  flags,
@@ -127,6 +109,36 @@ export async function functionTestCore(options) {
127
109
  log(`↳ END: invoke('${run.name}')`);
128
110
  }
129
111
  };
112
+ /**
113
+ * CLI lines for a durable run, in emission order.
114
+ * @param events
115
+ */
116
+ function renderStepTimeline(events) {
117
+ const lines = [];
118
+ for (const event of events) {
119
+ if (event.kind === 'log') {
120
+ lines.push(`${event.timestamp} ${event.level.toUpperCase()} ${event.message}`);
121
+ continue;
122
+ }
123
+ if (event.phase === 'start') {
124
+ // mark the start of the function being invoked.
125
+ if (event.kind === 'delegate') {
126
+ lines.push(` ${STEP_PHASE_GLYPH[event.phase]} ${event.kind} -> ${event.name}`);
127
+ }
128
+ continue;
129
+ }
130
+ // Only show nested logs for invoked functions.
131
+ // @todo: we may have to show for other events as we add more steps
132
+ if (event.kind === 'delegate') {
133
+ logNestedRuns();
134
+ }
135
+ const glyph = STEP_PHASE_GLYPH[event.phase] ?? '•';
136
+ const duration = typeof event.durationMs === 'number' ? ` (${Math.round(event.durationMs)}ms)` : '';
137
+ const detail = event.phase === 'error' && event.error ? ` ${event.error.message}` : '';
138
+ lines.push(` ${glyph} ${event.name} [${event.kind}]${duration}${detail}`);
139
+ }
140
+ return lines;
141
+ }
130
142
  const spinner = log.ora('Executing function...').start();
131
143
  const { json: response, logs, error, } = await testAction(resource, invokeOptions, context, {
132
144
  timeout: timeout ? timeout : resource.timeout,
@@ -1,6 +1,7 @@
1
1
  import { readLocalBlueprint } from '../actions/blueprints/blueprint.js';
2
2
  import { getStack, resolveStackIdByNameOrId } from '../actions/blueprints/stacks.js';
3
3
  import { presentBlueprintParserErrors } from '../utils/display/errors.js';
4
+ import { createTracedFetch } from '../utils/traced-fetch.js';
4
5
  import { validTokenOrErrorMessage } from '../utils/validated-token.js';
5
6
  export async function initBlueprintConfig({ bin, log, token, validateResources = false, validateToken = true, blueprintPath, }) {
6
7
  let checkedToken = token;
@@ -51,7 +52,8 @@ export async function initDeployedBlueprintConfig(config) {
51
52
  return { ok: false, error: 'Missing Stack deployment configuration for Blueprint' };
52
53
  }
53
54
  const spinner = config.log.ora('Loading Stack deployment...').start();
54
- const stackResponse = await getStack({ stackId, auth, logger: config.log });
55
+ const fetchFn = createTracedFetch(config.log);
56
+ const stackResponse = await getStack({ stackId, auth, fetchFn });
55
57
  if (!stackResponse.ok) {
56
58
  spinner.fail('Could not load Stack deployment');
57
59
  return { ok: false, error: 'Missing Stack deployment' };
@@ -16,7 +16,7 @@ export async function handleInvokeRequest(functionName, event, metadata, context
16
16
  resources,
17
17
  stackId,
18
18
  auth: config.token && scopeType && scopeId ? { token: config.token, scopeType, scopeId } : undefined,
19
- logger: silentLogger,
19
+ fetchFn: fetch,
20
20
  });
21
21
  const payload = {
22
22
  payload: event,
@@ -1,11 +1,10 @@
1
1
  import type { BlueprintResource } from '@sanity/blueprints';
2
- import type { Logger } from '../logger.js';
3
2
  import type { AuthParams } from '../types.js';
4
3
  interface ResolveOptions {
5
4
  resources: BlueprintResource[];
6
5
  stackId?: string;
7
6
  auth?: AuthParams;
8
- logger: Logger;
7
+ fetchFn: typeof fetch;
9
8
  }
10
9
  /**
11
10
  * Returns the local blueprint resources, annotated with externalIds from the
@@ -13,5 +12,5 @@ interface ResolveOptions {
13
12
  * resources that aren't yet deployed are preserved without an externalId.
14
13
  * Any failure to fetch the stack falls back to the raw local resources.
15
14
  */
16
- export declare function resolveDeployedResources({ resources, stackId, auth, logger, }: ResolveOptions): Promise<BlueprintResource[]>;
15
+ export declare function resolveDeployedResources({ resources, stackId, auth, fetchFn, }: ResolveOptions): Promise<BlueprintResource[]>;
17
16
  export {};
@@ -5,11 +5,11 @@ import { getStack } from '../../actions/blueprints/stacks.js';
5
5
  * resources that aren't yet deployed are preserved without an externalId.
6
6
  * Any failure to fetch the stack falls back to the raw local resources.
7
7
  */
8
- export async function resolveDeployedResources({ resources, stackId, auth, logger, }) {
8
+ export async function resolveDeployedResources({ resources, stackId, auth, fetchFn, }) {
9
9
  if (!stackId || !auth?.token || !auth.scopeType || !auth.scopeId)
10
10
  return resources;
11
11
  try {
12
- const response = await getStack({ stackId, auth, logger });
12
+ const response = await getStack({ stackId, auth, fetchFn });
13
13
  if (!response.ok || !Array.isArray(response.stack?.resources))
14
14
  return resources;
15
15
  const deployedByKey = new Map();
@@ -0,0 +1,60 @@
1
+ import type { Logger } from '../logger.js';
2
+ import type { BlueprintLog } from '../types.js';
3
+ /** Delay before the first operation poll. Override with SANITY_OPERATION_POLL_INITIAL_DELAY_MS. */
4
+ export declare const OPERATION_POLL_INITIAL_DELAY_MS: number;
5
+ /** Interval between operation status polls. Override with SANITY_OPERATION_POLL_INTERVAL_MS. */
6
+ export declare const OPERATION_POLL_INTERVAL_MS: number;
7
+ /** Consecutive failed polls tolerated before giving up on confirming status. */
8
+ export declare const OPERATION_POLL_MAX_CONSECUTIVE_ERRORS = 5;
9
+ /** Result of waiting for a deploy/destroy operation to settle. */
10
+ export type OperationOutcome = {
11
+ type: 'completed';
12
+ } | {
13
+ type: 'failed';
14
+ logHints: string[];
15
+ } | {
16
+ type: 'unconfirmed';
17
+ error?: string;
18
+ };
19
+ type EndState = 'completed' | 'failed';
20
+ export type StateInfo = {
21
+ state: EndState;
22
+ } | {
23
+ state: 'pending';
24
+ /** Used to avoid repeatedly outputting pending status messages. */
25
+ latestStatus: string;
26
+ verboseMessage: string;
27
+ } | {
28
+ state: 'not_found';
29
+ verboseError: string;
30
+ } | {
31
+ state: 'error';
32
+ error: string | null;
33
+ verboseError: string;
34
+ };
35
+ type WaitForOptions = {
36
+ /**
37
+ * Requests the latest state and returns it to the main loop for processing.
38
+ * @returns The current state of the thing we are waiting for
39
+ */
40
+ onRequestLatestState: () => Promise<StateInfo>;
41
+ /**
42
+ * Called when the waitFor logic should start reading logs.
43
+ * @returns A function that stops the polling process when invoked.
44
+ */
45
+ onStartLogPolling: (options: {
46
+ onActivity?: () => void;
47
+ onLogEntry?: (log: BlueprintLog, previousLog: BlueprintLog | undefined) => void;
48
+ }) => () => void;
49
+ getIdleMessage: () => string;
50
+ };
51
+ export type WaitForFn = (options: WaitForOptions) => Promise<OperationOutcome>;
52
+ export declare function createWaitFor({ bin, log, verbose, initialDelayMs, pollIntervalMs, idleSpinnerDelayMs, }: {
53
+ bin: string;
54
+ log: Logger;
55
+ verbose?: boolean;
56
+ initialDelayMs?: number;
57
+ pollIntervalMs?: number;
58
+ idleSpinnerDelayMs?: number;
59
+ }): WaitForFn;
60
+ export {};
@@ -0,0 +1,115 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ import { formatLogEntry } from '../display/logs-formatting.js';
3
+ import { runCommand } from '../package-manager.js';
4
+ import { createHintCollector } from './hints.js';
5
+ const envMs = (value, fallback) => {
6
+ if (!value)
7
+ return fallback;
8
+ const parsed = Number(value);
9
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
10
+ };
11
+ /** Delay before the first operation poll. Override with SANITY_OPERATION_POLL_INITIAL_DELAY_MS. */
12
+ export const OPERATION_POLL_INITIAL_DELAY_MS = envMs(process.env.SANITY_OPERATION_POLL_INITIAL_DELAY_MS, 1000);
13
+ /** Interval between operation status polls. Override with SANITY_OPERATION_POLL_INTERVAL_MS. */
14
+ export const OPERATION_POLL_INTERVAL_MS = envMs(process.env.SANITY_OPERATION_POLL_INTERVAL_MS, 1500);
15
+ /** Consecutive failed polls tolerated before giving up on confirming status. */
16
+ export const OPERATION_POLL_MAX_CONSECUTIVE_ERRORS = 5;
17
+ const IDLE_SPINNER_DELAY_MS = 30_000;
18
+ const IDLE_SPINNER_UPDATE_INTERVAL_MS = 15_000;
19
+ export function createWaitFor({ bin, log, verbose, initialDelayMs = OPERATION_POLL_INITIAL_DELAY_MS, pollIntervalMs = OPERATION_POLL_INTERVAL_MS, idleSpinnerDelayMs = IDLE_SPINNER_DELAY_MS, }) {
20
+ return async (options) => {
21
+ const logHints = createHintCollector(bin);
22
+ let logStreamCleanup = null;
23
+ let idleSpinner;
24
+ const stopIdleSpinner = (restoreOperationProgress = false) => {
25
+ if (!idleSpinner)
26
+ return;
27
+ idleSpinner.stop();
28
+ idleSpinner = undefined;
29
+ if (restoreOperationProgress)
30
+ log.showProgress();
31
+ };
32
+ try {
33
+ log.showProgress();
34
+ let lastLogAt = Date.now();
35
+ let nextIdleSpinnerUpdateAt = idleSpinnerDelayMs;
36
+ let consecutiveErrors = 0;
37
+ let lastStatus;
38
+ logStreamCleanup = options.onStartLogPolling({
39
+ onActivity: () => {
40
+ lastLogAt = Date.now();
41
+ nextIdleSpinnerUpdateAt = idleSpinnerDelayMs;
42
+ stopIdleSpinner(true);
43
+ },
44
+ onLogEntry: (logEntry, previousLog) => {
45
+ logHints.inspectLog(logEntry);
46
+ log(formatLogEntry(logEntry, verbose, previousLog));
47
+ },
48
+ });
49
+ // balk in case read replica lags
50
+ await sleep(initialDelayMs);
51
+ while (true) {
52
+ const stateInfo = await options.onRequestLatestState();
53
+ if (stateInfo.state === 'completed') {
54
+ if (logStreamCleanup)
55
+ logStreamCleanup();
56
+ stopIdleSpinner();
57
+ log('');
58
+ return { type: 'completed' };
59
+ }
60
+ if (stateInfo.state === 'failed') {
61
+ if (logStreamCleanup)
62
+ logStreamCleanup();
63
+ stopIdleSpinner();
64
+ log('');
65
+ return { type: 'failed', logHints: logHints.getSuggestions() };
66
+ }
67
+ // tolerate a few consecutive errors; the operation was accepted and may still be running
68
+ if (stateInfo.state === 'error') {
69
+ consecutiveErrors += 1;
70
+ log.verbose(`${stateInfo.verboseError}; retrying (${consecutiveErrors}/${OPERATION_POLL_MAX_CONSECUTIVE_ERRORS}).`);
71
+ if (consecutiveErrors >= OPERATION_POLL_MAX_CONSECUTIVE_ERRORS) {
72
+ if (logStreamCleanup)
73
+ logStreamCleanup();
74
+ stopIdleSpinner();
75
+ log('');
76
+ return { type: 'unconfirmed', error: stateInfo.error ?? undefined };
77
+ }
78
+ }
79
+ else {
80
+ consecutiveErrors = 0;
81
+ if (stateInfo.state === 'not_found') {
82
+ log.verbose(stateInfo.verboseError);
83
+ }
84
+ else if (stateInfo.state === 'pending' && stateInfo.latestStatus !== lastStatus) {
85
+ log.verbose(stateInfo.verboseMessage);
86
+ lastStatus = stateInfo.latestStatus;
87
+ }
88
+ }
89
+ const idleFor = Date.now() - lastLogAt;
90
+ if (idleFor >= nextIdleSpinnerUpdateAt) {
91
+ const elapsedIdleMs = idleSpinnerDelayMs +
92
+ Math.floor((idleFor - idleSpinnerDelayMs) / IDLE_SPINNER_UPDATE_INTERVAL_MS) *
93
+ IDLE_SPINNER_UPDATE_INTERVAL_MS;
94
+ const elapsedIdleSeconds = elapsedIdleMs / 1000;
95
+ const text = `No new activity for ${elapsedIdleSeconds} seconds. ${options.getIdleMessage()} You can safely exit and check status later with \`${runCommand(bin)} blueprints info\`.`;
96
+ if (idleSpinner)
97
+ idleSpinner.start(text);
98
+ else
99
+ idleSpinner = log.ora(text).start();
100
+ nextIdleSpinnerUpdateAt = elapsedIdleMs + IDLE_SPINNER_UPDATE_INTERVAL_MS;
101
+ }
102
+ await sleep(pollIntervalMs);
103
+ }
104
+ }
105
+ catch (error) {
106
+ if (logStreamCleanup)
107
+ logStreamCleanup();
108
+ throw error;
109
+ }
110
+ finally {
111
+ stopIdleSpinner();
112
+ log.clearProgress();
113
+ }
114
+ };
115
+ }
@@ -43,7 +43,6 @@ process.on('message', async (data) => {
43
43
  // `{srcPath, payload}` shape as well as the new `{type: 'start', ...}`.
44
44
  const {srcPath, payload, isDurable, noWait} = jsonData
45
45
  const {context, ...event} = payload
46
-
47
46
  let logs = ''
48
47
  let errorLogs = ''
49
48
  let json = null
@@ -87,6 +86,7 @@ process.on('message', async (data) => {
87
86
  {
88
87
  emit: (e) => process.send(JSON.stringify({type: 'step-event', event: e})),
89
88
  wait: (ms) => new Promise((resolve) => setTimeout(resolve, noWait ? 0 : ms)),
89
+ invoke: invoker.invoke,
90
90
  },
91
91
  )
92
92
  } else {
@@ -107,6 +107,14 @@ export async function runDurable(handler, {context, event}, io) {
107
107
  }
108
108
  return state
109
109
  }),
110
+ delegate: ({name, handler, event}) =>
111
+ track(name || '', 'delegate', () => {
112
+ const fn = typeof handler === 'string' ? handler : handler?.name
113
+ if (!fn) {
114
+ throw new Error('No function name provided for delegation')
115
+ }
116
+ return io.invoke(fn, event)
117
+ }),
110
118
  }
111
119
 
112
120
  return handler({context, event, step, logger})
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Asserts that a given source file does not contain any `console` usage.
3
+ * @param src
4
+ */
5
+ export declare const assertConsole: (src: string) => void;
@@ -0,0 +1,60 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, extname, resolve } from 'node:path';
3
+ import { parseSync, Visitor } from 'vite';
4
+ const EXTENSIONS = ['', '.ts', '.js', '.mjs', '.cjs'];
5
+ /**
6
+ * Resolves a local module if it exists.
7
+ * @param fromFile
8
+ * @param specifier
9
+ */
10
+ const resolveLocalModule = (fromFile, specifier) => {
11
+ const base = resolve(dirname(fromFile), specifier);
12
+ const candidates = [
13
+ base,
14
+ base.replace(/\.js$/, '.ts'),
15
+ ...EXTENSIONS.map((ext) => base + ext),
16
+ ...EXTENSIONS.map((ext) => resolve(base, `index${ext}`)),
17
+ ];
18
+ return candidates.find((candidate) => EXTENSIONS.includes(extname(candidate)) && existsSync(candidate) && !candidate.endsWith('/'));
19
+ };
20
+ /**
21
+ * Asserts that a given source file does not contain any `console` usage.
22
+ * @param src
23
+ */
24
+ export const assertConsole = (src) => {
25
+ const seen = new Set();
26
+ const stack = [src];
27
+ while (stack.length > 0) {
28
+ const file = stack.pop();
29
+ if (!file || seen.has(file)) {
30
+ continue;
31
+ }
32
+ seen.add(file);
33
+ const { program, module: esm, errors } = parseSync(file, readFileSync(file, 'utf8'));
34
+ if (errors.length > 0) {
35
+ throw new Error(`Failed to parse ${file}: ${errors.map((e) => e.message).join(', ')}`);
36
+ }
37
+ new Visitor({
38
+ MemberExpression(node) {
39
+ if (node.object.type === 'Identifier' && node.object.name === 'console') {
40
+ // @todo: improve error message to include doc link.
41
+ const err = new Error('Console logging is not allowed in Durable functions. ' +
42
+ `at ${file} ` +
43
+ 'Use the provided durable logger `logger.log()` or `ctx.log()` instead.');
44
+ err.stack = undefined;
45
+ throw err;
46
+ }
47
+ },
48
+ }).visit(program);
49
+ const isLocal = (str) => str.startsWith('./') || str.startsWith('../');
50
+ const imports = esm.staticImports.map((item) => item.moduleRequest.value);
51
+ const reExports = esm.staticExports.flatMap((exp) => exp.entries.flatMap((entry) => entry.moduleRequest && !entry.isType ? [entry.moduleRequest.value] : []));
52
+ const specs = [...imports, ...reExports].filter(isLocal);
53
+ for (const spec of specs) {
54
+ const resolved = resolveLocalModule(file, spec);
55
+ if (resolved) {
56
+ stack.push(resolved);
57
+ }
58
+ }
59
+ }
60
+ };
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { resolve } from 'node:path';
2
3
  import { performance } from 'node:perf_hooks';
3
4
  import { cwd } from 'node:process';
4
5
  import { setTimeout } from 'node:timers';
@@ -8,6 +9,8 @@ import config from '../../../config.js';
8
9
  import { SANITY_FUNCTION_DURABLE } from '../../../constants.js';
9
10
  import { groupBlueprintResources } from '../../blueprints/group-resources.js';
10
11
  import { isGroqContextOptions, } from '../../types.js';
12
+ import { findFunctionEntryPoint } from '../find-entry-point.js';
13
+ import { assertConsole } from './assert-console.js';
11
14
  import { acquireFunctionBuild } from './prepare.js';
12
15
  import { isGroqInvocation } from './resolve-invoke-type.js';
13
16
  function getChildProcessWrapperPath() {
@@ -48,6 +51,12 @@ export default async function invoke(resource, payload, context, options) {
48
51
  throw new Error(`Function resource "${resource.name}" is missing the 'src' property.`);
49
52
  }
50
53
  const { forceColor = true, timeout = 10, onNestedInvoke, depth = 0, callStack = [], onStepEvent = () => { }, noWait = false, } = options;
54
+ // Ensure a user when running a durable is using the provided logger and not the `console`
55
+ if (resource.type === SANITY_FUNCTION_DURABLE) {
56
+ const sourcePath = resolve(context.blueprintFileDir, resource.src);
57
+ const entry = await findFunctionEntryPoint(sourcePath, resource?.displayName || resource.name);
58
+ assertConsole(entry);
59
+ }
51
60
  // `payload.payload` is the event body for every function type: the document for
52
61
  // GROQ-based events, whatever the producer sent for the rest. The handler always
53
62
  // receives it as `event.data` (see the `payload` built in `start()` below).
@@ -0,0 +1,5 @@
1
+ import { type PathLike } from 'node:fs';
2
+ /**
3
+ * Recursively validates the total size of a folder, including all nested files and subdirectories.
4
+ */
5
+ export declare function isFolderLargerThan(folder: PathLike, maxBytes: number): boolean;
@@ -0,0 +1,38 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ /**
4
+ * Recursively validates the total size of a folder, including all nested files and subdirectories.
5
+ */
6
+ export function isFolderLargerThan(folder, maxBytes) {
7
+ if (!statSync(folder).isDirectory()) {
8
+ throw new Error('Provided path is not a directory');
9
+ }
10
+ function recurse(f, bytesSoFar) {
11
+ let totalBytes = bytesSoFar;
12
+ const fileEntries = readdirSync(f);
13
+ for (const file of fileEntries) {
14
+ const filePath = path.join(f.toString(), file);
15
+ const stats = statSync(filePath);
16
+ if (stats.isFile()) {
17
+ totalBytes += stats.size;
18
+ }
19
+ else if (stats.isDirectory()) {
20
+ const result = recurse(filePath, totalBytes);
21
+ totalBytes = result.totalBytes;
22
+ }
23
+ // return early if we have already exceeded the maximum
24
+ if (totalBytes > maxBytes) {
25
+ return {
26
+ totalBytes,
27
+ exceededMax: true,
28
+ };
29
+ }
30
+ }
31
+ return {
32
+ totalBytes,
33
+ exceededMax: false,
34
+ };
35
+ }
36
+ const result = recurse(folder, 0);
37
+ return result.exceededMax;
38
+ }
@@ -1,6 +1,8 @@
1
+ import AdmZip from 'adm-zip';
1
2
  import type { AssetPrepResult, FunctionResource, InstallerType } from '../types.js';
2
3
  export declare function prepareAsset({ blueprintFileDir, resource, installer, }: {
3
4
  blueprintFileDir: string;
4
5
  resource: FunctionResource;
5
6
  installer?: InstallerType;
6
7
  }): Promise<AssetPrepResult>;
8
+ export declare function pathToZip(path: string): Promise<AdmZip>;
@@ -1,9 +1,10 @@
1
- import { existsSync } from 'node:fs';
1
+ import fs, { existsSync } from 'node:fs';
2
2
  import { rm, stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { MAX_ASSET_SIZE } from '../../constants.js';
4
+ import AdmZip from 'adm-zip';
5
+ import { CONVERT_BYTES_TO_MB, MAX_ASSET_SIZE } from '../../constants.js';
5
6
  import { detectNativeModules, errorMessage } from './detect-native-modules.js';
6
- import { getFolderSize } from './get-folder-size.js';
7
+ import { isFolderLargerThan } from './is-folder-larger-than.js';
7
8
  import { resolveResourceDependencies } from './resolve-dependencies.js';
8
9
  import { shouldAutoResolveDependencies } from './should-auto-resolve-deps.js';
9
10
  import { shouldTranspileFunction } from './should-transpile.js';
@@ -51,16 +52,52 @@ export async function prepareAsset({ blueprintFileDir, resource, installer, }) {
51
52
  }
52
53
  }
53
54
  try {
54
- const size = getFolderSize(functionPath);
55
- if (size > MAX_ASSET_SIZE) {
55
+ // First check the size before trying to create the zip file
56
+ const result = isFolderLargerThan(functionPath, MAX_ASSET_SIZE);
57
+ if (result) {
56
58
  throw new Error('Resource is larger than max asset size of 200 MB.');
57
59
  }
58
- return { success: true, outputPath: functionPath, cleanup };
60
+ const zip = await pathToZip(functionPath);
61
+ // Add up the uncompressed sizes of all entries to get a more accurate size of the asset being uploaded
62
+ const zipEntrySize = zip
63
+ .getEntries()
64
+ .map((entry) => entry.header.size)
65
+ .reduce((a, b) => a + b, 0);
66
+ if (zipEntrySize > MAX_ASSET_SIZE) {
67
+ throw new Error(`Asset size of ${(zipEntrySize / CONVERT_BYTES_TO_MB).toFixed(2)} MB exceeds the maximum allowed size of 200 MB.`);
68
+ }
69
+ return {
70
+ success: true,
71
+ outputBuffer: await zip.toBufferPromise(),
72
+ fileExtension: 'zip',
73
+ assetParameter: 'src',
74
+ cleanup,
75
+ };
59
76
  }
60
77
  catch (err) {
61
78
  return { success: false, cleanup, error: err instanceof Error ? err.message : `${err}` };
62
79
  }
63
80
  }
81
+ export async function pathToZip(path) {
82
+ const stats = await stat(path);
83
+ const zip = new AdmZip(undefined, {
84
+ fs: {
85
+ ...fs,
86
+ // @ts-expect-error monkey patching to return matching mtime for builds
87
+ statSync(path, options) {
88
+ const s = fs.statSync(path, options);
89
+ if (s)
90
+ s.mtime = new Date(0);
91
+ return s;
92
+ },
93
+ },
94
+ });
95
+ if (stats.isDirectory())
96
+ zip.addLocalFolder(path);
97
+ else
98
+ zip.addLocalFile(path, '', 'index.js');
99
+ return zip;
100
+ }
64
101
  /**
65
102
  * Remove file that contains the current date to prevent the hash from changing in the final asset.
66
103
  * @param functionPath The path to the built function