@sanity/runtime-cli 16.0.3 → 17.1.0

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 (41) hide show
  1. package/README.md +27 -22
  2. package/dist/actions/functions/test.js +1 -1
  3. package/dist/baseCommands.js +13 -2
  4. package/dist/commands/blueprints/init.js +5 -2
  5. package/dist/config.d.ts +1 -0
  6. package/dist/config.js +7 -1
  7. package/dist/constants.d.ts +1 -0
  8. package/dist/constants.js +1 -0
  9. package/dist/cores/blueprints/init.js +16 -2
  10. package/dist/cores/functions/test.js +44 -0
  11. package/dist/server/app.js +31 -3
  12. package/dist/server/handlers/invoke.d.ts +2 -2
  13. package/dist/server/handlers/invoke.js +7 -4
  14. package/dist/server/static/components/console-panel.js +59 -8
  15. package/dist/server/static/components/rule-panel.js +11 -8
  16. package/dist/server/static/components/shared-styles.js +24 -21
  17. package/dist/server/static/index.html +1 -1
  18. package/dist/server/static/vendor/design-tokens/color.css +48 -48
  19. package/dist/server/static/vendor/design-tokens/index.css +0 -1
  20. package/dist/server/static/vendor/vendor.bundle.js +3807 -3259
  21. package/dist/server/static/web-socket.js +41 -0
  22. package/dist/utils/functions/child-process-wrapper.js +62 -7
  23. package/dist/utils/functions/{invoke-local.d.ts → invoke/local.d.ts} +1 -1
  24. package/dist/utils/functions/invoke/local.js +306 -0
  25. package/dist/utils/functions/invoke/nested.d.ts +25 -0
  26. package/dist/utils/functions/invoke/nested.js +41 -0
  27. package/dist/utils/functions/invoke/prepare.d.ts +20 -0
  28. package/dist/utils/functions/invoke/prepare.js +90 -0
  29. package/dist/utils/interactive.d.ts +1 -0
  30. package/dist/utils/interactive.js +4 -0
  31. package/dist/utils/isTrueish.d.ts +6 -0
  32. package/dist/utils/isTrueish.js +9 -0
  33. package/dist/utils/logger.d.ts +1 -0
  34. package/dist/utils/logger.js +1 -0
  35. package/dist/utils/style-text.js +4 -5
  36. package/dist/utils/traced-fetch.js +2 -0
  37. package/dist/utils/types.d.ts +62 -4
  38. package/oclif.manifest.json +928 -927
  39. package/package.json +18 -14
  40. package/dist/server/static/hot-reload.js +0 -16
  41. package/dist/utils/functions/invoke-local.js +0 -185
@@ -0,0 +1,41 @@
1
+ import apiConstructor from '../api.js'
2
+
3
+ const api = apiConstructor()
4
+
5
+ // Create WebSocket connection.
6
+ const socket = new WebSocket('ws://localhost:8974')
7
+
8
+ // Connection opened
9
+ socket.addEventListener('open', () => {
10
+ console.log('Watching for Blueprint changes')
11
+ })
12
+
13
+ // Listen for messages
14
+ socket.addEventListener('message', (event) => {
15
+ if (event.data === 'reload-blueprint') {
16
+ api.blueprint()
17
+ return
18
+ }
19
+
20
+ let message
21
+ try {
22
+ message = JSON.parse(event.data)
23
+ } catch {
24
+ return
25
+ }
26
+
27
+ if (message?.type === 'nested-invoke') {
28
+ window.dispatchEvent(
29
+ new CustomEvent('nested-invoke', {
30
+ detail: {
31
+ name: message.name,
32
+ order: message.order,
33
+ logs: message.logs,
34
+ json: message.json,
35
+ error: message.error,
36
+ timings: message.timings,
37
+ },
38
+ }),
39
+ )
40
+ }
41
+ })
@@ -76,7 +76,34 @@ console.error = (...args) => {
76
76
  process.stdout.write(`${logPrefix('error', ...args)}\n`)
77
77
  }
78
78
 
79
- // Start when payload data arrives from parent process
79
+ /**
80
+ * Pending nested `context.invoke` calls, keyed by correlation id. Each entry
81
+ * holds the resolve/reject of the Promise returned to the user's handler; it is
82
+ * settled when the matching `invoke-response` arrives from the parent.
83
+ * @type {Map<string, {resolve: (value: unknown) => void, reject: (reason: Error) => void}>}
84
+ */
85
+ const pendingInvokes = new Map()
86
+ let invokeSeq = 0
87
+
88
+ /**
89
+ * `context.invoke(name, payload)` — ask the parent to execute another blueprint
90
+ * function and resolve with its return value. Sent over IPC and correlated by id.
91
+ * @param {string} name
92
+ * @param {Record<string, unknown>} [payload]
93
+ * @returns {Promise<unknown>}
94
+ */
95
+ function invokeFunction(name, payload = {}) {
96
+ if (typeof name !== 'string' || name.length === 0) {
97
+ return Promise.reject(new Error('context.invoke requires a function name'))
98
+ }
99
+ const id = String(++invokeSeq)
100
+ return new Promise((resolve, reject) => {
101
+ pendingInvokes.set(id, {resolve, reject})
102
+ process.send(JSON.stringify({type: 'invoke-request', id, name, payload}))
103
+ })
104
+ }
105
+
106
+ // Listen for IPC messages from the parent process.
80
107
  process.on('message', async (data) => {
81
108
  let jsonData = null
82
109
  try {
@@ -86,6 +113,25 @@ process.on('message', async (data) => {
86
113
  return
87
114
  }
88
115
 
116
+ // Response to a nested context.invoke() — route to the awaiting Promise and stop.
117
+ if (jsonData?.type === 'invoke-response') {
118
+ const pending = pendingInvokes.get(jsonData.id)
119
+ if (!pending) return
120
+ pendingInvokes.delete(jsonData.id)
121
+ if (jsonData.ok) {
122
+ pending.resolve(jsonData.result?.json)
123
+ } else {
124
+ const {message, stack, name} = jsonData.error ?? {}
125
+ const error = new Error(message ?? 'Nested invoke failed')
126
+ if (stack) error.stack = stack
127
+ if (name) error.name = name
128
+ pending.reject(error)
129
+ }
130
+ return
131
+ }
132
+
133
+ // Otherwise this is the initial start message. Support the legacy untyped
134
+ // `{srcPath, payload}` shape as well as the new `{type: 'start', ...}`.
89
135
  const {srcPath, payload} = jsonData
90
136
  const {context, ...event} = payload
91
137
 
@@ -93,17 +139,22 @@ process.on('message', async (data) => {
93
139
  let errorLogs = ''
94
140
  let json = null
95
141
 
96
- // Capture stdout and stderr
142
+ // Capture stdout and stderr in-memory for the terminal `result` message, and
143
+ // ALSO send them through to the real stdio pipes. The parent captures those
144
+ // pipes live, so logs written before a timeout (child killed) or a
145
+ // process.exit() aren't lost
97
146
  const originalStdoutWrite = process.stdout.write.bind(process.stdout)
98
147
  const originalStderrWrite = process.stderr.write.bind(process.stderr)
99
148
 
100
- process.stdout.write = (chunk) => {
149
+ process.stdout.write = (...args) => {
150
+ const chunk = args[0]
101
151
  if (typeof chunk === 'string') logs += chunk
102
- return true
152
+ return originalStdoutWrite(...args)
103
153
  }
104
- process.stderr.write = (chunk) => {
154
+ process.stderr.write = (...args) => {
155
+ const chunk = args[0]
105
156
  if (typeof chunk === 'string') errorLogs += chunk
106
- return true
157
+ return originalStderrWrite(...args)
107
158
  }
108
159
 
109
160
  try {
@@ -120,6 +171,9 @@ process.on('message', async (data) => {
120
171
  // Replace resources JSON with Resources API
121
172
  context.resources = createResourcesApi(context.resources)
122
173
 
174
+ // Allow the handler to invoke other blueprint functions by name.
175
+ context.invoke = invokeFunction
176
+
123
177
  json = await eventHandler({
124
178
  context,
125
179
  event,
@@ -132,13 +186,14 @@ process.on('message', async (data) => {
132
186
  process.stderr.write = originalStderrWrite
133
187
 
134
188
  // Send result to parent
135
- process.send(JSON.stringify({json, logs, errorLogs}))
189
+ process.send(JSON.stringify({type: 'result', json, logs, errorLogs}))
136
190
  } catch (err) {
137
191
  // Restore streams on error
138
192
  process.stdout.write = originalStdoutWrite
139
193
  process.stderr.write = originalStderrWrite
140
194
 
141
195
  const errorInfo = {
196
+ type: 'result',
142
197
  json: null,
143
198
  logs,
144
199
  errorLogs,
@@ -1,4 +1,4 @@
1
- import { type FunctionGroqResource, type FunctionResource, type InvocationResponse, type InvokeContextOptions, type InvokeExecutionOptions, type InvokeGroqPayloadOptions, type InvokePayloadOptions } from '../types.js';
1
+ import { type FunctionGroqResource, type FunctionResource, type InvocationResponse, type InvokeContextOptions, type InvokeExecutionOptions, type InvokeGroqPayloadOptions, type InvokePayloadOptions } from '../../types.js';
2
2
  export declare function sanitizeLogs(logs: string): string;
3
3
  export declare function applyGroqRule(resource: FunctionGroqResource, payload: InvokeGroqPayloadOptions, projectId: string | undefined, dataset: string | undefined): Promise<any>;
4
4
  export default function invoke(resource: FunctionResource, payload: InvokePayloadOptions, context: InvokeContextOptions, options: InvokeExecutionOptions): Promise<InvocationResponse>;
@@ -0,0 +1,306 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { performance } from 'node:perf_hooks';
3
+ import { cwd } from 'node:process';
4
+ import { setTimeout } from 'node:timers';
5
+ import { fileURLToPath } from 'node:url';
6
+ import * as groq from 'groq-js';
7
+ import config from '../../../config.js';
8
+ import { groupBlueprintResources } from '../../blueprints/group-resources.js';
9
+ import { isGroqContextOptions, } from '../../types.js';
10
+ import { acquireFunctionBuild } from './prepare.js';
11
+ function getChildProcessWrapperPath() {
12
+ return fileURLToPath(new URL('../child-process-wrapper.js', import.meta.url));
13
+ }
14
+ export function sanitizeLogs(logs) {
15
+ return logs.replace(/([a-zA-Z0-9]{10})[a-zA-Z0-9]{65,}/g, '$1**********');
16
+ }
17
+ export async function applyGroqRule(resource, payload, projectId, dataset) {
18
+ const { before, after, payload: data = null } = payload;
19
+ // If there is no rule set return everything
20
+ if (!resource.event)
21
+ return data;
22
+ // default groq rule is: gimme full doc content. otherwise, parse + eval custom rule
23
+ // applying the GROQ filter may result in a slimmer set of documents
24
+ if (resource.event.filter || resource.event.projection) {
25
+ const query = `*[${resource.event.filter || ''}]${resource.event.projection || ''}`;
26
+ try {
27
+ const rule = groq.parse(query, { mode: 'delta' });
28
+ const sanity = projectId && dataset ? { projectId, dataset } : undefined;
29
+ const queryResults = await groq.evaluate(rule, { dataset: [data], before, after, sanity });
30
+ const currentFunctionDocumentSet = await queryResults.get();
31
+ return currentFunctionDocumentSet[0];
32
+ }
33
+ catch (groqErr) {
34
+ const detail = groqErr instanceof Error && `; ${groqErr.message}`;
35
+ throw new Error(`Invalid rule configuration: ${query}${detail}`, { cause: groqErr });
36
+ }
37
+ }
38
+ // default groq rule so just return the data
39
+ return data;
40
+ }
41
+ export default async function invoke(resource, payload, context, options) {
42
+ if (!resource.src) {
43
+ throw new Error(`Function resource "${resource.name}" is missing the 'src' property.`);
44
+ }
45
+ const { forceColor = true, timeout = 10, onNestedInvoke, depth = 0, callStack = [] } = options;
46
+ // Apply the GROQ rule before preparing a build so a filtered-out event skips
47
+ // the (potentially expensive) transpile entirely.
48
+ let filteredData = payload?.payload?.data || {};
49
+ if (resource.type === 'sanity.function.document' ||
50
+ resource.type === 'sanity.function.media-library.asset') {
51
+ if (!isGroqContextOptions(context)) {
52
+ throw new Error('GROQ-based functions require a context with clientOptions');
53
+ }
54
+ const { projectId, dataset } = context.clientOptions;
55
+ if ('before' in payload) {
56
+ filteredData = await applyGroqRule(resource, payload, projectId, dataset);
57
+ if (typeof filteredData === 'undefined') {
58
+ return {
59
+ logs: `⚠️ Filter "${resource.event?.filter}" returned an empty result. Skipping invoke.`,
60
+ error: undefined,
61
+ json: undefined,
62
+ };
63
+ }
64
+ }
65
+ }
66
+ // Acquire a shared, reference-counted build. Concurrent invokes of the same
67
+ // function reuse one transpile + temp package.json instead of racing on the
68
+ // per-function build paths; the build is torn down once every invoke releases.
69
+ const build = acquireFunctionBuild(resource);
70
+ let functionPath;
71
+ let bundleTimings;
72
+ try {
73
+ const prepared = await build.ready;
74
+ functionPath = prepared.functionPath;
75
+ bundleTimings = prepared.bundleTimings;
76
+ }
77
+ catch (err) {
78
+ await build.release();
79
+ throw err;
80
+ }
81
+ return new Promise((resolve, reject) => {
82
+ let child;
83
+ let timer;
84
+ let executionStart;
85
+ // The protocol is no longer one-shot: the child stays alive across nested
86
+ // `context.invoke` calls, so several handlers (result/exit/error/timeout)
87
+ // can race to settle this Promise. `settled` ensures exactly one wins and
88
+ // that shutdown/cleanup only runs once.
89
+ let settled = false;
90
+ // Timeout budget is paused while nested invokes run so a function's own
91
+ // time limit isn't consumed by the functions it calls.
92
+ let pendingNested = 0;
93
+ let timerStart = 0;
94
+ let remaining = timeout * 1000;
95
+ const nestedTimings = {};
96
+ // Output the child writes directly to its stdio pipes (anything that escapes
97
+ // the in-process console capture, e.g. a native crash). Hoisted to the
98
+ // Promise scope so both the timeout and exit handlers can surface it.
99
+ let stdout = '';
100
+ let stderr = '';
101
+ function startTimer() {
102
+ timerStart = performance.now();
103
+ timer = setTimeout(() => {
104
+ if (settled)
105
+ return;
106
+ settled = true;
107
+ const executionTimeMs = performance.now() - executionStart;
108
+ shutdown();
109
+ // Resolve with error to retain all logs
110
+ resolve({
111
+ json: undefined,
112
+ logs: sanitizeLogs(stdout),
113
+ error: {
114
+ name: 'Error',
115
+ message: `Timeout: Function exceeded the ${timeout}-second time limit.`,
116
+ stack: '',
117
+ },
118
+ timings: {
119
+ ...bundleTimings,
120
+ ...nestedTimings,
121
+ execute: executionTimeMs,
122
+ },
123
+ });
124
+ }, Math.max(0, remaining));
125
+ }
126
+ function pauseTimer() {
127
+ if (pendingNested === 0) {
128
+ clearTimeout(timer);
129
+ remaining -= performance.now() - timerStart;
130
+ }
131
+ pendingNested += 1;
132
+ }
133
+ function resumeTimer() {
134
+ pendingNested -= 1;
135
+ if (pendingNested === 0 && !settled) {
136
+ startTimer();
137
+ }
138
+ }
139
+ // Resolve a nested `context.invoke(name, payload)` by re-entering the invoke
140
+ // pipeline via the supplied callback, then hand the result back to the child.
141
+ async function handleNestedInvoke(msg) {
142
+ const { id, name, payload: nestedPayload } = msg;
143
+ pauseTimer();
144
+ const nestedStart = performance.now();
145
+ try {
146
+ if (!onNestedInvoke) {
147
+ throw new Error('Nested context.invoke is not supported in this context');
148
+ }
149
+ const nested = await onNestedInvoke(name, nestedPayload ?? {}, context, depth + 1, callStack);
150
+ nestedTimings[`invoke:${name}`] =
151
+ (nestedTimings[`invoke:${name}`] ?? 0) + (performance.now() - nestedStart);
152
+ // Fire-and-forget contract: in production `context.invoke` returns once the
153
+ // message is enqueued (Lambda InvocationType:'Event', SNS/SQS), so a
154
+ // downstream handler error never reaches the caller. Mirror that here —
155
+ // the target ran (it was "dispatched"), so resolve the caller regardless
156
+ // of `nested.error`. The error isn't lost: it travels back in the returned
157
+ // result and is logged/streamed to the UI by the caller (app/test core).
158
+ if (child && !child.killed) {
159
+ const response = {
160
+ type: 'invoke-response',
161
+ id,
162
+ ok: true,
163
+ result: { json: nested.json },
164
+ };
165
+ child.send(JSON.stringify(response));
166
+ }
167
+ }
168
+ catch (error) {
169
+ // The invoke could not be dispatched at all (unknown function, recursion
170
+ // guard, unsupported context). That is an enqueue-time failure, which in
171
+ // production rejects synchronously, so propagate it to the caller.
172
+ if (child && !child.killed) {
173
+ const response = {
174
+ type: 'invoke-response',
175
+ id,
176
+ ok: false,
177
+ error: {
178
+ message: error instanceof Error ? error.message : String(error),
179
+ stack: error instanceof Error ? (error.stack ?? '') : '',
180
+ name: error instanceof Error ? error.name : 'Error',
181
+ },
182
+ };
183
+ child.send(JSON.stringify(response));
184
+ }
185
+ }
186
+ finally {
187
+ resumeTimer();
188
+ }
189
+ }
190
+ function start() {
191
+ executionStart = performance.now();
192
+ child = spawn('node', ['--enable-source-maps', getChildProcessWrapperPath()], {
193
+ cwd: cwd(),
194
+ stdio: ['pipe', 'pipe', 'pipe', 'ipc'], // changed: use 'pipe' so we can capture output
195
+ env: { ...process.env, ...resource.env, FORCE_COLOR: forceColor ? '1' : '0' },
196
+ });
197
+ child.stdout?.on('data', (data) => {
198
+ stdout += data.toString();
199
+ });
200
+ child.stderr?.on('data', (data) => {
201
+ stderr += data.toString();
202
+ });
203
+ child.on('message', (data) => {
204
+ let msg;
205
+ try {
206
+ msg = JSON.parse(data.toString());
207
+ }
208
+ catch {
209
+ return;
210
+ }
211
+ // Nested invoke request — handle without tearing down the child.
212
+ if (msg?.type === 'invoke-request') {
213
+ handleNestedInvoke({ id: msg.id, name: msg.name, payload: msg.payload });
214
+ return;
215
+ }
216
+ // Terminal result message.
217
+ if (settled)
218
+ return;
219
+ settled = true;
220
+ const executionTimeMs = performance.now() - executionStart;
221
+ shutdown();
222
+ resolve({
223
+ json: msg.json,
224
+ logs: sanitizeLogs(msg.logs ?? ''),
225
+ error: msg.error,
226
+ timings: {
227
+ ...bundleTimings,
228
+ ...nestedTimings,
229
+ execute: executionTimeMs,
230
+ },
231
+ });
232
+ });
233
+ child.on('error', (error) => {
234
+ if (settled)
235
+ return;
236
+ settled = true;
237
+ shutdown();
238
+ reject(new Error(`encountered error ${error.message}`));
239
+ });
240
+ // Resolve on 'close' to drain the pipes
241
+ child.on('close', (code) => {
242
+ if (settled)
243
+ return;
244
+ settled = true;
245
+ const executionTimeMs = performance.now() - executionStart;
246
+ shutdown();
247
+ if (code !== 0) {
248
+ // use captured stderr if available
249
+ const message = stderr.trim().length > 0
250
+ ? `Process failed with code ${code}\n${stderr}`
251
+ : `exited with code ${code}`;
252
+ // Resolve with error to retain all logs
253
+ resolve({
254
+ json: undefined,
255
+ logs: sanitizeLogs(stdout),
256
+ error: { name: 'Error', message, stack: '' },
257
+ timings: {
258
+ ...bundleTimings,
259
+ ...nestedTimings,
260
+ execute: executionTimeMs,
261
+ },
262
+ });
263
+ }
264
+ else {
265
+ resolve({
266
+ json: undefined,
267
+ logs: sanitizeLogs(stdout),
268
+ error: undefined,
269
+ timings: {
270
+ ...bundleTimings,
271
+ ...nestedTimings,
272
+ execute: executionTimeMs,
273
+ },
274
+ });
275
+ }
276
+ });
277
+ startTimer();
278
+ const payload = {
279
+ data: { ...filteredData },
280
+ context: {
281
+ ...context,
282
+ local: true,
283
+ resources: groupBlueprintResources(context.resources ?? []),
284
+ ...(isGroqContextOptions(context) && {
285
+ clientOptions: {
286
+ ...context.clientOptions,
287
+ apiHost: config.apiUrl,
288
+ },
289
+ }),
290
+ },
291
+ };
292
+ const startMessage = { type: 'start', srcPath: functionPath, payload };
293
+ child.send(JSON.stringify(startMessage));
294
+ }
295
+ function shutdown() {
296
+ clearTimeout(timer);
297
+ if (child && !child.killed) {
298
+ child.kill();
299
+ }
300
+ // Release our reference to the shared build; its temp package.json and
301
+ // bundle are cleaned up once the last concurrent invoke releases.
302
+ build.release().catch((err) => console.warn('Build cleanup failed:', err));
303
+ }
304
+ start();
305
+ });
306
+ }
@@ -0,0 +1,25 @@
1
+ import type { FunctionResource, InvocationResponse, InvokeContextOptions, InvokeExecutionOptions, InvokePayloadOptions } from '../../types.js';
2
+ /**
3
+ * Upper bound on nested `context.invoke` recursion, a backstop against invoke loops.
4
+ * Set to 16 to match Functions service lineage limit.
5
+ */
6
+ export declare const MAX_INVOKE_DEPTH = 16;
7
+ /**
8
+ * Guard a nested `context.invoke(name)` against runaway recursion and cycles.
9
+ * `depth` is the depth at which the target would run and `callStack` is the chain
10
+ * of function names already running (innermost last). Throws a descriptive error
11
+ * when the call must be rejected; returns normally when it is safe to proceed.
12
+ *
13
+ * This is an enqueue-time check: it runs before the target is dispatched, so the
14
+ * throw propagates to the caller's `context.invoke` (unlike a downstream handler
15
+ * error, which is fire-and-forget). See `handleNestedInvoke` in local.ts.
16
+ */
17
+ export declare function assertNestedInvokeAllowed(name: string, depth: number, callStack: string[]): void;
18
+ /**
19
+ * Execute a single already-resolved function in its own child process as one
20
+ * step of an invoke chain: enforce the depth/cycle guard, run it with the call
21
+ * stack extended by this function, and normalize timings.
22
+ */
23
+ export declare function runFunction(resource: FunctionResource, payload: InvokePayloadOptions, context: InvokeContextOptions, options: InvokeExecutionOptions): Promise<InvocationResponse & {
24
+ timings: Record<string, number>;
25
+ }>;
@@ -0,0 +1,41 @@
1
+ import invoke from './local.js';
2
+ /**
3
+ * Upper bound on nested `context.invoke` recursion, a backstop against invoke loops.
4
+ * Set to 16 to match Functions service lineage limit.
5
+ */
6
+ export const MAX_INVOKE_DEPTH = 16;
7
+ /**
8
+ * Guard a nested `context.invoke(name)` against runaway recursion and cycles.
9
+ * `depth` is the depth at which the target would run and `callStack` is the chain
10
+ * of function names already running (innermost last). Throws a descriptive error
11
+ * when the call must be rejected; returns normally when it is safe to proceed.
12
+ *
13
+ * This is an enqueue-time check: it runs before the target is dispatched, so the
14
+ * throw propagates to the caller's `context.invoke` (unlike a downstream handler
15
+ * error, which is fire-and-forget). See `handleNestedInvoke` in local.ts.
16
+ */
17
+ export function assertNestedInvokeAllowed(name, depth, callStack) {
18
+ if (depth > MAX_INVOKE_DEPTH) {
19
+ throw new Error(`Maximum invoke depth (${MAX_INVOKE_DEPTH}) exceeded`);
20
+ }
21
+ if (callStack.includes(name)) {
22
+ throw new Error(`Invoke cycle detected: ${[...callStack, name].join(' → ')}`);
23
+ }
24
+ }
25
+ /**
26
+ * Execute a single already-resolved function in its own child process as one
27
+ * step of an invoke chain: enforce the depth/cycle guard, run it with the call
28
+ * stack extended by this function, and normalize timings.
29
+ */
30
+ export async function runFunction(resource, payload, context, options) {
31
+ const { forceColor, timeout, depth = 0, callStack = [], onNestedInvoke } = options;
32
+ assertNestedInvokeAllowed(resource.name, depth, callStack);
33
+ const response = await invoke(resource, payload, context, {
34
+ forceColor,
35
+ timeout: timeout ?? resource.timeout,
36
+ onNestedInvoke,
37
+ depth,
38
+ callStack: [...callStack, resource.name],
39
+ });
40
+ return { ...response, timings: response.timings ?? {} };
41
+ }
@@ -0,0 +1,20 @@
1
+ import type { FunctionResource } from '../../types.js';
2
+ /** Everything a child process needs to run a prepared function. */
3
+ export interface PreparedFunction {
4
+ /** Absolute path to the entry file the child should import. */
5
+ functionPath: string;
6
+ /** Timings recorded while bundling, merged into the invocation result. */
7
+ bundleTimings?: Record<string, number>;
8
+ }
9
+ /** A reference-counted handle to a shared build. Call `release()` once per acquire. */
10
+ export interface FunctionBuild {
11
+ ready: Promise<PreparedFunction>;
12
+ release: () => Promise<void>;
13
+ }
14
+ /**
15
+ * Acquire a shared, reference-counted build for `resource`. The first caller runs
16
+ * the transpile + temp package.json setup; concurrent callers reuse it. Every
17
+ * caller MUST call `release()` exactly once when its child has exited, so the
18
+ * shared artifacts are torn down only after the last in-flight invoke finishes.
19
+ */
20
+ export declare function acquireFunctionBuild(resource: FunctionResource): FunctionBuild;
@@ -0,0 +1,90 @@
1
+ import { findFunctionEntryPoint } from '../find-entry-point.js';
2
+ import { cleanupTempPackageJson, createTempPackageJson, doesPackageJsonExists, } from '../package-json.js';
3
+ import { resolveResourceDependencies } from '../resolve-dependencies.js';
4
+ import { shouldAutoResolveDependencies } from '../should-auto-resolve-deps.js';
5
+ import { shouldTranspileFunction } from '../should-transpile.js';
6
+ import { transpileFunction } from '../transpile/function.js';
7
+ /**
8
+ * In-progress builds keyed by function identity. Concurrent invokes of the same
9
+ * function (e.g. `Promise.all([context.invoke('f'), context.invoke('f')])`) share
10
+ * a single build and a single temp package.json, so they don't race on the
11
+ * deterministic per-function paths (`.build/function-<name>`, the temp
12
+ * package.json). The entry is removed once the last concurrent invoke releases
13
+ * it, so a later, non-overlapping invoke rebuilds and picks up source changes.
14
+ */
15
+ const builds = new Map();
16
+ function buildKey(resource) {
17
+ return `${resource.name}:${resource.src}`;
18
+ }
19
+ /**
20
+ * Acquire a shared, reference-counted build for `resource`. The first caller runs
21
+ * the transpile + temp package.json setup; concurrent callers reuse it. Every
22
+ * caller MUST call `release()` exactly once when its child has exited, so the
23
+ * shared artifacts are torn down only after the last in-flight invoke finishes.
24
+ */
25
+ export function acquireFunctionBuild(resource) {
26
+ const key = buildKey(resource);
27
+ let entry = builds.get(key);
28
+ if (!entry) {
29
+ let cleanupBundle = async () => { };
30
+ let functionPath = '';
31
+ let createdTempPackageJson = false;
32
+ // If no package.json exists we create a temporary one to avoid the
33
+ // "module type" warning from node.
34
+ const existingPackageJson = doesPackageJsonExists(resource);
35
+ const ready = (async () => {
36
+ if (!resource.src) {
37
+ throw new Error(`Function resource "${resource.name}" is missing the 'src' property.`);
38
+ }
39
+ let bundleTimings;
40
+ const shouldTranspile = await shouldTranspileFunction(resource);
41
+ if (shouldTranspile) {
42
+ const transpileResult = await transpileFunction(resource);
43
+ functionPath = await findFunctionEntryPoint(transpileResult.outputDir);
44
+ bundleTimings = transpileResult.timings;
45
+ cleanupBundle = transpileResult.cleanup;
46
+ }
47
+ else {
48
+ functionPath = await findFunctionEntryPoint(resource.src, resource.displayName ?? resource.name);
49
+ }
50
+ const shouldResolveDependencies = await shouldAutoResolveDependencies(resource);
51
+ if (shouldResolveDependencies && existingPackageJson) {
52
+ await resolveResourceDependencies(resource, { transpiled: shouldTranspile });
53
+ }
54
+ if (!existingPackageJson) {
55
+ createTempPackageJson(functionPath);
56
+ createdTempPackageJson = true;
57
+ }
58
+ return { functionPath, bundleTimings };
59
+ })();
60
+ entry = {
61
+ refs: 0,
62
+ ready,
63
+ teardown: async () => {
64
+ // rmSync is synchronous, so the temp package.json is gone before this
65
+ // function yields — a later invoke of the same function can't observe a
66
+ // half-removed state.
67
+ if (createdTempPackageJson) {
68
+ cleanupTempPackageJson(functionPath);
69
+ }
70
+ await cleanupBundle().catch((err) => console.warn('Bundle cleanup failed:', err));
71
+ },
72
+ };
73
+ builds.set(key, entry);
74
+ }
75
+ const activeEntry = entry;
76
+ activeEntry.refs += 1;
77
+ let released = false;
78
+ const release = async () => {
79
+ if (released)
80
+ return;
81
+ released = true;
82
+ activeEntry.refs -= 1;
83
+ if (activeEntry.refs === 0) {
84
+ if (builds.get(key) === activeEntry)
85
+ builds.delete(key);
86
+ await activeEntry.teardown();
87
+ }
88
+ };
89
+ return { ready: activeEntry.ready, release };
90
+ }
@@ -0,0 +1 @@
1
+ export declare const isInteractive: boolean;
@@ -0,0 +1,4 @@
1
+ function testIsInteractive() {
2
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
3
+ }
4
+ export const isInteractive = testIsInteractive();
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Quick check for 'true' or '1' from env
3
+ * @param val
4
+ */
5
+ declare const isTrueish: (val?: string) => boolean;
6
+ export default isTrueish;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Quick check for 'true' or '1' from env
3
+ * @param val
4
+ */
5
+ const isTrueish = (val) => {
6
+ const v = (val ?? '').toLowerCase();
7
+ return v === 'true' || parseInt(v, 10) > 0;
8
+ };
9
+ export default isTrueish;