@sanity/runtime-cli 4.4.0 → 4.5.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 (38) hide show
  1. package/README.md +17 -17
  2. package/dist/actions/blueprints/assets.d.ts +1 -0
  3. package/dist/actions/blueprints/assets.js +21 -4
  4. package/dist/actions/functions/test.d.ts +2 -2
  5. package/dist/actions/functions/test.js +2 -2
  6. package/dist/commands/functions/test.js +3 -6
  7. package/dist/server/app.js +80 -12
  8. package/dist/server/static/api.js +24 -3
  9. package/dist/server/static/components/function-list.js +4 -4
  10. package/dist/server/static/components/response-panel.js +14 -3
  11. package/dist/server/static/index.html +1 -0
  12. package/dist/server/static/vendor/vendor.bundle.d.ts +2 -2
  13. package/dist/utils/build-payload.d.ts +1 -1
  14. package/dist/utils/build-payload.js +3 -3
  15. package/dist/utils/bundle/bundle-function.d.ts +8 -0
  16. package/dist/utils/bundle/bundle-function.js +125 -0
  17. package/dist/utils/bundle/cleanup-source-maps.d.ts +10 -0
  18. package/dist/utils/bundle/cleanup-source-maps.js +53 -0
  19. package/dist/utils/bundle/find-up.d.ts +16 -0
  20. package/dist/utils/bundle/find-up.js +39 -0
  21. package/dist/utils/bundle/verify-handler.d.ts +2 -0
  22. package/dist/utils/bundle/verify-handler.js +13 -0
  23. package/dist/utils/child-process-wrapper.js +8 -6
  24. package/dist/utils/functions/find-entry-point.d.ts +11 -0
  25. package/dist/utils/functions/find-entry-point.js +75 -0
  26. package/dist/utils/functions/should-bundle.d.ts +2 -0
  27. package/dist/utils/functions/should-bundle.js +23 -0
  28. package/dist/utils/invoke-local.d.ts +2 -2
  29. package/dist/utils/invoke-local.js +49 -8
  30. package/dist/utils/is-record.d.ts +1 -0
  31. package/dist/utils/is-record.js +3 -0
  32. package/dist/utils/parse-json-object.d.ts +1 -0
  33. package/dist/utils/parse-json-object.js +10 -0
  34. package/dist/utils/types.d.ts +3 -1
  35. package/oclif.manifest.json +1 -1
  36. package/package.json +4 -1
  37. package/dist/utils/is-json.d.ts +0 -1
  38. package/dist/utils/is-json.js +0 -12
package/README.md CHANGED
@@ -20,7 +20,7 @@ $ npm install -g @sanity/runtime-cli
20
20
  $ sanity-run COMMAND
21
21
  running command...
22
22
  $ sanity-run (--version)
23
- @sanity/runtime-cli/4.4.0 linux-x64 node-v22.15.0
23
+ @sanity/runtime-cli/4.5.0 linux-x64 node-v22.15.0
24
24
  $ sanity-run --help [COMMAND]
25
25
  USAGE
26
26
  $ sanity-run COMMAND
@@ -74,7 +74,7 @@ EXAMPLES
74
74
  $ sanity-run blueprints add function --name my-function --function-type document-publish
75
75
  ```
76
76
 
77
- _See code: [src/commands/blueprints/add.ts](https://github.com/sanity-io/runtime-cli/blob/v4.4.0/src/commands/blueprints/add.ts)_
77
+ _See code: [src/commands/blueprints/add.ts](https://github.com/sanity-io/runtime-cli/blob/v4.5.0/src/commands/blueprints/add.ts)_
78
78
 
79
79
  ## `sanity-run blueprints config`
80
80
 
@@ -103,7 +103,7 @@ EXAMPLES
103
103
  $ sanity-run blueprints config --edit --project-id <projectId> --stack-id <stackId>
104
104
  ```
105
105
 
106
- _See code: [src/commands/blueprints/config.ts](https://github.com/sanity-io/runtime-cli/blob/v4.4.0/src/commands/blueprints/config.ts)_
106
+ _See code: [src/commands/blueprints/config.ts](https://github.com/sanity-io/runtime-cli/blob/v4.5.0/src/commands/blueprints/config.ts)_
107
107
 
108
108
  ## `sanity-run blueprints deploy`
109
109
 
@@ -123,7 +123,7 @@ EXAMPLES
123
123
  $ sanity-run blueprints deploy
124
124
  ```
125
125
 
126
- _See code: [src/commands/blueprints/deploy.ts](https://github.com/sanity-io/runtime-cli/blob/v4.4.0/src/commands/blueprints/deploy.ts)_
126
+ _See code: [src/commands/blueprints/deploy.ts](https://github.com/sanity-io/runtime-cli/blob/v4.5.0/src/commands/blueprints/deploy.ts)_
127
127
 
128
128
  ## `sanity-run blueprints destroy`
129
129
 
@@ -146,7 +146,7 @@ EXAMPLES
146
146
  $ sanity-run blueprints destroy --id ST-a1b2c3
147
147
  ```
148
148
 
149
- _See code: [src/commands/blueprints/destroy.ts](https://github.com/sanity-io/runtime-cli/blob/v4.4.0/src/commands/blueprints/destroy.ts)_
149
+ _See code: [src/commands/blueprints/destroy.ts](https://github.com/sanity-io/runtime-cli/blob/v4.5.0/src/commands/blueprints/destroy.ts)_
150
150
 
151
151
  ## `sanity-run blueprints info`
152
152
 
@@ -168,7 +168,7 @@ EXAMPLES
168
168
  $ sanity-run blueprints info --id ST-a1b2c3
169
169
  ```
170
170
 
171
- _See code: [src/commands/blueprints/info.ts](https://github.com/sanity-io/runtime-cli/blob/v4.4.0/src/commands/blueprints/info.ts)_
171
+ _See code: [src/commands/blueprints/info.ts](https://github.com/sanity-io/runtime-cli/blob/v4.5.0/src/commands/blueprints/info.ts)_
172
172
 
173
173
  ## `sanity-run blueprints init`
174
174
 
@@ -198,7 +198,7 @@ EXAMPLES
198
198
  $ sanity-run blueprints init --blueprint-type <json|js|ts> --project-id <projectId> --stack-name <stackName>
199
199
  ```
200
200
 
201
- _See code: [src/commands/blueprints/init.ts](https://github.com/sanity-io/runtime-cli/blob/v4.4.0/src/commands/blueprints/init.ts)_
201
+ _See code: [src/commands/blueprints/init.ts](https://github.com/sanity-io/runtime-cli/blob/v4.5.0/src/commands/blueprints/init.ts)_
202
202
 
203
203
  ## `sanity-run blueprints logs`
204
204
 
@@ -220,7 +220,7 @@ EXAMPLES
220
220
  $ sanity-run blueprints logs --watch
221
221
  ```
222
222
 
223
- _See code: [src/commands/blueprints/logs.ts](https://github.com/sanity-io/runtime-cli/blob/v4.4.0/src/commands/blueprints/logs.ts)_
223
+ _See code: [src/commands/blueprints/logs.ts](https://github.com/sanity-io/runtime-cli/blob/v4.5.0/src/commands/blueprints/logs.ts)_
224
224
 
225
225
  ## `sanity-run blueprints plan`
226
226
 
@@ -237,7 +237,7 @@ EXAMPLES
237
237
  $ sanity-run blueprints plan
238
238
  ```
239
239
 
240
- _See code: [src/commands/blueprints/plan.ts](https://github.com/sanity-io/runtime-cli/blob/v4.4.0/src/commands/blueprints/plan.ts)_
240
+ _See code: [src/commands/blueprints/plan.ts](https://github.com/sanity-io/runtime-cli/blob/v4.5.0/src/commands/blueprints/plan.ts)_
241
241
 
242
242
  ## `sanity-run blueprints stacks`
243
243
 
@@ -259,7 +259,7 @@ EXAMPLES
259
259
  $ sanity-run blueprints stacks --projectId a1b2c3
260
260
  ```
261
261
 
262
- _See code: [src/commands/blueprints/stacks.ts](https://github.com/sanity-io/runtime-cli/blob/v4.4.0/src/commands/blueprints/stacks.ts)_
262
+ _See code: [src/commands/blueprints/stacks.ts](https://github.com/sanity-io/runtime-cli/blob/v4.5.0/src/commands/blueprints/stacks.ts)_
263
263
 
264
264
  ## `sanity-run functions dev`
265
265
 
@@ -279,7 +279,7 @@ EXAMPLES
279
279
  $ sanity-run functions dev --port 8974
280
280
  ```
281
281
 
282
- _See code: [src/commands/functions/dev.ts](https://github.com/sanity-io/runtime-cli/blob/v4.4.0/src/commands/functions/dev.ts)_
282
+ _See code: [src/commands/functions/dev.ts](https://github.com/sanity-io/runtime-cli/blob/v4.5.0/src/commands/functions/dev.ts)_
283
283
 
284
284
  ## `sanity-run functions env add NAME KEY VALUE`
285
285
 
@@ -301,7 +301,7 @@ EXAMPLES
301
301
  $ sanity-run functions env add MyFunction API_URL https://api.example.com/
302
302
  ```
303
303
 
304
- _See code: [src/commands/functions/env/add.ts](https://github.com/sanity-io/runtime-cli/blob/v4.4.0/src/commands/functions/env/add.ts)_
304
+ _See code: [src/commands/functions/env/add.ts](https://github.com/sanity-io/runtime-cli/blob/v4.5.0/src/commands/functions/env/add.ts)_
305
305
 
306
306
  ## `sanity-run functions env list NAME`
307
307
 
@@ -321,7 +321,7 @@ EXAMPLES
321
321
  $ sanity-run functions env list MyFunction
322
322
  ```
323
323
 
324
- _See code: [src/commands/functions/env/list.ts](https://github.com/sanity-io/runtime-cli/blob/v4.4.0/src/commands/functions/env/list.ts)_
324
+ _See code: [src/commands/functions/env/list.ts](https://github.com/sanity-io/runtime-cli/blob/v4.5.0/src/commands/functions/env/list.ts)_
325
325
 
326
326
  ## `sanity-run functions env remove NAME KEY`
327
327
 
@@ -342,7 +342,7 @@ EXAMPLES
342
342
  $ sanity-run functions env remove MyFunction API_URL
343
343
  ```
344
344
 
345
- _See code: [src/commands/functions/env/remove.ts](https://github.com/sanity-io/runtime-cli/blob/v4.4.0/src/commands/functions/env/remove.ts)_
345
+ _See code: [src/commands/functions/env/remove.ts](https://github.com/sanity-io/runtime-cli/blob/v4.5.0/src/commands/functions/env/remove.ts)_
346
346
 
347
347
  ## `sanity-run functions invoke NAME`
348
348
 
@@ -368,7 +368,7 @@ EXAMPLES
368
368
  $ sanity-run functions invoke <name> --file 'payload.json'
369
369
  ```
370
370
 
371
- _See code: [src/commands/functions/invoke.ts](https://github.com/sanity-io/runtime-cli/blob/v4.4.0/src/commands/functions/invoke.ts)_
371
+ _See code: [src/commands/functions/invoke.ts](https://github.com/sanity-io/runtime-cli/blob/v4.5.0/src/commands/functions/invoke.ts)_
372
372
 
373
373
  ## `sanity-run functions logs NAME`
374
374
 
@@ -400,7 +400,7 @@ EXAMPLES
400
400
  $ sanity-run functions logs <name> --delete
401
401
  ```
402
402
 
403
- _See code: [src/commands/functions/logs.ts](https://github.com/sanity-io/runtime-cli/blob/v4.4.0/src/commands/functions/logs.ts)_
403
+ _See code: [src/commands/functions/logs.ts](https://github.com/sanity-io/runtime-cli/blob/v4.5.0/src/commands/functions/logs.ts)_
404
404
 
405
405
  ## `sanity-run functions test NAME`
406
406
 
@@ -433,7 +433,7 @@ EXAMPLES
433
433
  $ sanity-run functions test <name> --data '{ "id": 1 }' --timeout 60
434
434
  ```
435
435
 
436
- _See code: [src/commands/functions/test.ts](https://github.com/sanity-io/runtime-cli/blob/v4.4.0/src/commands/functions/test.ts)_
436
+ _See code: [src/commands/functions/test.ts](https://github.com/sanity-io/runtime-cli/blob/v4.5.0/src/commands/functions/test.ts)_
437
437
 
438
438
  ## `sanity-run help [COMMAND]`
439
439
 
@@ -6,5 +6,6 @@ export declare function stashAsset({ resource, auth, }: {
6
6
  }): Promise<{
7
7
  success: boolean;
8
8
  assetId?: string;
9
+ outputPath?: string;
9
10
  error?: string;
10
11
  }>;
@@ -3,21 +3,35 @@ import path from 'node:path';
3
3
  import { cwd } from 'node:process';
4
4
  import AdmZip from 'adm-zip';
5
5
  import config from '../../config.js';
6
+ import { bundleFunction } from '../../utils/bundle/bundle-function.js';
7
+ import { shouldBundleFunction } from '../../utils/functions/should-bundle.js';
6
8
  import getHeaders from '../../utils/get-headers.js';
7
9
  const { apiUrl } = config;
8
10
  export const stashUrl = `${apiUrl}vX/blueprints/assets/stash`;
9
11
  export async function stashAsset({ resource, auth, }) {
10
12
  if (!resource.src)
11
13
  throw new Error('Resource src is required');
14
+ let functionPath = path.join(cwd(), resource.src);
15
+ let cleanup = async () => { };
16
+ const shouldBundle = await shouldBundleFunction(resource);
17
+ if (shouldBundle) {
18
+ try {
19
+ const result = await bundleFunction(resource);
20
+ functionPath = result.outputDir;
21
+ cleanup = result.cleanup;
22
+ }
23
+ catch (err) {
24
+ return { success: false, error: err instanceof Error ? err.message : `${err}` };
25
+ }
26
+ }
12
27
  try {
13
- const sourcePath = path.join(cwd(), resource.src);
14
- const stats = await fs.promises.stat(sourcePath);
28
+ const stats = await fs.promises.stat(functionPath);
15
29
  const zip = new AdmZip();
16
30
  if (stats.isDirectory()) {
17
- zip.addLocalFolder(sourcePath);
31
+ zip.addLocalFolder(functionPath);
18
32
  }
19
33
  else {
20
- zip.addLocalFile(sourcePath, '', 'index.js');
34
+ zip.addLocalFile(functionPath, '', 'index.js');
21
35
  }
22
36
  const zipBuffer = zip.toBuffer();
23
37
  const base64Zip = zipBuffer.toString('base64');
@@ -41,4 +55,7 @@ export async function stashAsset({ resource, auth, }) {
41
55
  error = err.message;
42
56
  return { success: false, error };
43
57
  }
58
+ finally {
59
+ await cleanup();
60
+ }
44
61
  }
@@ -1,2 +1,2 @@
1
- import type { InvocationResponse, InvokeContextOptions, InvokePayloadOptions } from '../../utils/types.js';
2
- export declare function testAction(srcPath: string, options: InvokePayloadOptions, context: InvokeContextOptions): Promise<InvocationResponse>;
1
+ import type { InvocationResponse, InvokeContextOptions, InvokePayloadOptions, LocalFunctionResource } from '../../utils/types.js';
2
+ export declare function testAction(resource: LocalFunctionResource, options: InvokePayloadOptions, context: InvokeContextOptions): Promise<InvocationResponse>;
@@ -1,10 +1,10 @@
1
1
  import buildPayload from '../../utils/build-payload.js';
2
2
  import invoke from '../../utils/invoke-local.js';
3
- export async function testAction(srcPath, options, context) {
3
+ export async function testAction(resource, options, context) {
4
4
  const payload = buildPayload(options);
5
5
  const { timeout } = options;
6
6
  try {
7
- const { json, logs } = await invoke(srcPath, payload, context, timeout);
7
+ const { json, logs } = await invoke(resource, payload, context, timeout);
8
8
  return { error: undefined, json, logs };
9
9
  }
10
10
  catch (error) {
@@ -1,7 +1,7 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
2
  import { readBlueprintOnDisk } from '../../actions/blueprints/blueprint.js';
3
3
  import { testAction } from '../../actions/functions/test.js';
4
- import { getFunctionSource } from '../../utils/find-function.js';
4
+ import { findFunctionByName } from '../../utils/find-function.js';
5
5
  export default class Test extends Command {
6
6
  static args = {
7
7
  name: Args.string({ description: 'The name of the Sanity Function', required: true }),
@@ -42,11 +42,8 @@ export default class Test extends Command {
42
42
  const { args, flags } = await this.parse(Test);
43
43
  const { parsedBlueprint } = await readBlueprintOnDisk({ getStack: false });
44
44
  try {
45
- const src = getFunctionSource(parsedBlueprint, args.name);
46
- if (!src) {
47
- this.error(`Error: Function ${args.name} has no source code`);
48
- }
49
- const { json, logs, error } = await testAction(src, {
45
+ const resource = findFunctionByName(parsedBlueprint, args.name);
46
+ const { json, logs, error } = await testAction(resource, {
50
47
  data: flags.data,
51
48
  file: flags.file,
52
49
  timeout: flags.timeout,
@@ -1,9 +1,11 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
+ import * as http from 'node:http';
2
3
  import { default as mime } from 'mime-types';
3
4
  import { readBlueprintOnDisk } from '../actions/blueprints/blueprint.js';
4
5
  import config from '../config.js';
6
+ import { findFunctionByName } from '../utils/find-function.js';
5
7
  import invoke from '../utils/invoke-local.js';
6
- import * as http from 'node:http';
8
+ import { isRecord } from '../utils/is-record.js';
7
9
  const host = 'localhost';
8
10
  const app = (port) => {
9
11
  const requestListener = async (req, res) => {
@@ -14,30 +16,40 @@ const app = (port) => {
14
16
  const { parsedBlueprint } = await readBlueprintOnDisk({ getStack: false });
15
17
  res.setHeader('Content-Type', 'application/json');
16
18
  res.writeHead(200);
17
- res.end(JSON.stringify(parsedBlueprint));
19
+ res.end(JSON.stringify(parsedBlueprint)); // Use parsedBlueprint directly
18
20
  }
19
- catch {
21
+ catch (error) {
20
22
  res.writeHead(404);
21
- res.end();
23
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Unknown error' }));
22
24
  }
23
25
  break;
24
26
  }
25
27
  case req.url === '/invoke': {
26
28
  if (req.method === 'POST') {
27
- let body = '';
28
- req.on('data', (data) => {
29
- body += data;
30
- });
29
+ const body = [];
30
+ req.on('data', (data) => body.push(data));
31
31
  req.on('end', async () => {
32
- const { data, func } = JSON.parse(body);
33
- const { context, event } = data;
34
32
  res.setHeader('Content-Type', 'application/json');
35
33
  try {
36
- const response = await invoke(func, event, context);
37
- res.writeHead(200);
34
+ const { data, func: functionName } = parseInvokeRequest(Buffer.concat(body));
35
+ const { context, event } = data;
36
+ const start = performance.now();
37
+ const { parsedBlueprint } = await readBlueprintOnDisk({ getStack: false });
38
+ const resource = findFunctionByName(parsedBlueprint, functionName);
39
+ const readBlueprintTime = performance.now() - start;
40
+ const response = await invoke(resource, event, context);
41
+ const timings = { ...response.timings, 'blueprint:read': readBlueprintTime };
42
+ const timingHeaders = [];
43
+ for (const [key, value] of Object.entries(timings)) {
44
+ timingHeaders.push(`${key.replace(/:/g, '-')};dur=${Math.abs(value).toFixed(1)}`);
45
+ }
46
+ if (timingHeaders.length > 0) {
47
+ res.setHeader('Server-Timing', timingHeaders.join(', '));
48
+ }
38
49
  res.end(JSON.stringify(response));
39
50
  }
40
51
  catch (error) {
52
+ console.error(error);
41
53
  const response = { logs: '', error: '' };
42
54
  if (error instanceof Error) {
43
55
  response.logs = error.message;
@@ -112,4 +124,60 @@ const app = (port) => {
112
124
  const server = http.createServer(requestListener);
113
125
  server.listen(port, host, () => { });
114
126
  };
127
+ function parseInvokeRequest(body) {
128
+ let json;
129
+ try {
130
+ json = JSON.parse(body.toString('utf8'));
131
+ }
132
+ catch (error) {
133
+ throw new Error('Request body is not valid JSON', { cause: error });
134
+ }
135
+ if (!isRecord(json)) {
136
+ throw new Error('Request body is not valid, must be an object');
137
+ }
138
+ if (!('func' in json)) {
139
+ throw new Error('Request body is not valid, missing `func` field');
140
+ }
141
+ if (!('data' in json)) {
142
+ throw new Error('Request body is not valid, missing `data` field');
143
+ }
144
+ const { data, func } = json;
145
+ if (typeof func !== 'string') {
146
+ throw new Error('Request body is not valid, `func` field is not a string');
147
+ }
148
+ if (!isRecord(data)) {
149
+ throw new Error('Request body is not valid, `data` field is not an object');
150
+ }
151
+ const { context, event } = data;
152
+ if (!isRecord(context)) {
153
+ throw new Error('Request body is not valid, `context` field is not an object');
154
+ }
155
+ if (!isRecord(event)) {
156
+ throw new Error('Request body is not valid, `event` field is not an object');
157
+ }
158
+ if (!('clientOptions' in context)) {
159
+ throw new Error('Request body is not valid, `context.clientOptions` field is missing');
160
+ }
161
+ if (!isRecord(context.clientOptions)) {
162
+ throw new Error('Request body is not valid, `context.clientOptions` field is not an object');
163
+ }
164
+ const { projectId, dataset, apiVersion } = context.clientOptions;
165
+ if (typeof projectId !== 'string' && typeof projectId !== 'undefined') {
166
+ throw new Error('Request body is not valid, `context.clientOptions.projectId` field is not a string');
167
+ }
168
+ if (typeof dataset !== 'string' && typeof dataset !== 'undefined') {
169
+ throw new Error('Request body is not valid, `context.clientOptions.dataset` field is not a string');
170
+ }
171
+ if (typeof apiVersion !== 'string' && typeof apiVersion !== 'undefined') {
172
+ throw new Error('Request body is not valid, `context.clientOptions.apiVersion` field is not a string');
173
+ }
174
+ const clientOptions = {
175
+ ...context.clientOptions,
176
+ projectId,
177
+ dataset,
178
+ // Prefer `undefined` over empty string, triggering the right warnings in the client
179
+ apiVersion: apiVersion || undefined,
180
+ };
181
+ return { func, data: { context: { ...context, clientOptions }, event } };
182
+ }
115
183
  export default app;
@@ -30,12 +30,15 @@ function invoke(payloadText = '{}') {
30
30
  },
31
31
  method: 'POST',
32
32
  })
33
- .then((response) => response.json())
34
- .then((data) => {
33
+ .then((response) => {
34
+ return response.json().then((data) => ({data, timings: getServerTimings(response)}))
35
+ })
36
+ .then(({data, timings}) => {
35
37
  store.inprogress = false
36
38
  store.result = {
37
39
  ...data,
38
40
  time: Date.now() - start,
41
+ timings,
39
42
  }
40
43
  })
41
44
  }
@@ -47,7 +50,7 @@ function blueprint() {
47
50
  const functions = blueprint?.resources.filter((r) => r.type.startsWith('sanity.function.'))
48
51
 
49
52
  store.functions = functions
50
- store.selectedIndex = functions[0].src
53
+ store.selectedIndex = functions[0].name
51
54
  })
52
55
  .catch(() => {
53
56
  store.functions = []
@@ -77,3 +80,21 @@ function datasets(selectedProject) {
77
80
  store.datasets = []
78
81
  })
79
82
  }
83
+
84
+ function getServerTimings(response) {
85
+ const timings = {}
86
+ const serverTiming = response.headers.get('Server-Timing')
87
+ if (!serverTiming) {
88
+ return timings
89
+ }
90
+
91
+ for (const entry of serverTiming.split(',')) {
92
+ const [name, ...params] = entry.split(';')
93
+ const durationParam = params.find((p) => p.startsWith('dur='))
94
+ if (durationParam) {
95
+ timings[name.trim()] = Number.parseFloat(durationParam.slice(4))
96
+ }
97
+ }
98
+
99
+ return timings
100
+ }
@@ -9,7 +9,7 @@ class FunctionList extends ApiBaseElement {
9
9
  functionClicked = (event) => {
10
10
  // eslint-disable-next-line unicorn/prefer-dom-node-text-content
11
11
  const target = this.api.store.functions.find((func) => func.name === event.srcElement.innerText)
12
- this.api.store.selectedIndex = target.src
12
+ this.api.store.selectedIndex = target.name
13
13
  }
14
14
  functionSelected = (event) => {
15
15
  this.api.store.selectedIndex = event.srcElement.value
@@ -18,14 +18,14 @@ class FunctionList extends ApiBaseElement {
18
18
  if (this.api.store.functions.length > 0) {
19
19
  this.list.innerHTML = this.api.store.functions
20
20
  .map((func) => {
21
- const selected = this.api.store.selectedIndex === func.src ? 'selected' : ''
21
+ const selected = this.api.store.selectedIndex === func.name ? 'selected' : ''
22
22
  return `<li class="function-list-item ${selected}" style="padding: 16px 24px;">${func.name}</li>`
23
23
  })
24
24
  .join('')
25
25
  this.select.innerHTML = this.api.store.functions
26
26
  .map((func) => {
27
- const selected = this.api.store.selectedIndex === func.src ? 'selected' : ''
28
- return `<option value="${func.src}" ${selected}>${func.name}</option>`
27
+ const selected = this.api.store.selectedIndex === func.name ? 'selected' : ''
28
+ return `<option value="${func.name}" ${selected}>${func.name}</option>`
29
29
  })
30
30
  .join('')
31
31
  } else {
@@ -34,7 +34,7 @@ class ResponsePanel extends ApiBaseElement {
34
34
  updateResponse = ({result}) => {
35
35
  if (!result) return
36
36
 
37
- const {error, json, time} = result
37
+ const {error, json, logs, time, timings} = result
38
38
  if (!error) {
39
39
  const transaction = this.api.store.response.state.update({
40
40
  changes: {
@@ -46,8 +46,19 @@ class ResponsePanel extends ApiBaseElement {
46
46
  this.api.store.response.dispatch(transaction)
47
47
 
48
48
  this.size.innerText = json ? prettyBytes(JSON.stringify(json).length) : ''
49
- this.time.innerText = prettyMilliseconds(time)
50
- this.time.dateTime = `PT${time / 1000}S`
49
+
50
+ if (timings && 'bundle' in timings && 'execute' in timings) {
51
+ const bundleTime = prettyMilliseconds(timings.bundle)
52
+ const executeTime = prettyMilliseconds(timings.execute)
53
+ this.time.innerText = `${executeTime} (+${bundleTime} bundle time)`
54
+ this.time.dateTime = `PT${executeTime / 1000}S`
55
+ } else if (timings && 'execute' in timings) {
56
+ this.time.innerText = prettyMilliseconds(timings.execute)
57
+ this.time.dateTime = `PT${timings.execute / 1000}S`
58
+ } else {
59
+ this.time.innerText = prettyMilliseconds(time)
60
+ this.time.dateTime = `PT${time / 1000}S`
61
+ }
51
62
  } else {
52
63
  const transaction = this.api.store.response.state.update({
53
64
  changes: {from: 0, to: this.api.store.response.state.doc.length, insert: ''},
@@ -1,6 +1,7 @@
1
1
  <!DOCTYPE html>
2
2
  <html>
3
3
  <head>
4
+ <meta charset="UTF-8">
4
5
  <title>Sanity Functions</title>
5
6
  <link href="https://unpkg.com/m-@3.2.0/dist/m-.woff2" rel="preload" as="font" crossorigin>
6
7
  <link href="https://unpkg.com/m-@3.2.0/dist/m-.css" rel="stylesheet">
@@ -1018,9 +1018,9 @@ declare class ViewState {
1018
1018
  viewport: Viewport | undefined;
1019
1019
  lineGaps: any[];
1020
1020
  lineGapDeco: any;
1021
- updateForViewport(): 0 | 2;
1021
+ updateForViewport(): 2 | 0;
1022
1022
  viewports: (Viewport | undefined)[] | undefined;
1023
- updateScaler(): 0 | 2;
1023
+ updateScaler(): 2 | 0;
1024
1024
  updateViewportLines(): void;
1025
1025
  viewportLines: any[] | undefined;
1026
1026
  update(update: any, scrollTarget?: null): void;
@@ -1,2 +1,2 @@
1
1
  import type { InvokePayloadOptions } from './types.js';
2
- export default function buildPayload(options: InvokePayloadOptions): object | null;
2
+ export default function buildPayload(options: InvokePayloadOptions): Record<string, unknown> | null;
@@ -1,15 +1,15 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { cwd } from 'node:process';
4
- import isJson from './is-json.js';
4
+ import { parseJsonObject } from './parse-json-object.js';
5
5
  export default function buildPayload(options) {
6
6
  const { data, file } = options;
7
7
  let payload = {};
8
8
  if (data) {
9
- payload = isJson(data);
9
+ payload = parseJsonObject(data);
10
10
  }
11
11
  else if (file) {
12
- payload = isJson(readFileSync(join(cwd(), file), 'utf8'));
12
+ payload = parseJsonObject(readFileSync(join(cwd(), file), 'utf8'));
13
13
  }
14
14
  return payload;
15
15
  }
@@ -0,0 +1,8 @@
1
+ import type { LocalFunctionResource } from '../types.js';
2
+ export declare function bundleFunction(resource: LocalFunctionResource): Promise<{
3
+ type: string;
4
+ outputDir: string;
5
+ warnings: string[];
6
+ cleanup: () => Promise<void>;
7
+ timings: Record<string, number>;
8
+ }>;
@@ -0,0 +1,125 @@
1
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { performance } from 'node:perf_hooks';
4
+ import { cwd } from 'node:process';
5
+ import { build as viteBuild } from 'vite';
6
+ import tsConfigPaths from 'vite-tsconfig-paths';
7
+ import { findFunctionEntryPoint } from '../functions/find-entry-point.js';
8
+ import { cleanupSourceMaps } from './cleanup-source-maps.js';
9
+ import { findDirUp } from './find-up.js';
10
+ import { verifyHandler } from './verify-handler.js';
11
+ export async function bundleFunction(resource) {
12
+ if (!resource.src)
13
+ throw new Error('Resource src is required');
14
+ if (!resource.name)
15
+ throw new Error('Resource name is required');
16
+ const timings = {};
17
+ const bundleStart = performance.now();
18
+ const sourcePath = path.resolve(cwd(), resource.src);
19
+ const stats = await stat(sourcePath);
20
+ const fnDisplayName = resource.displayName ?? resource.name;
21
+ const findEntryStart = performance.now();
22
+ const entry = await findFunctionEntryPoint(sourcePath, fnDisplayName);
23
+ timings['bundle:findEntry'] = performance.now() - findEntryStart;
24
+ const entryDir = stats.isFile() ? path.dirname(sourcePath) : sourcePath;
25
+ const outputPathStart = performance.now();
26
+ const outputDir = await getBundleOutputPath(entryDir, resource.name);
27
+ const outputFile = path.join(outputDir, getOutputFilename(entry));
28
+ const fnRootDir = (await findDirUp('node_modules', entryDir)) || entryDir;
29
+ timings['bundle:setupOutput'] = performance.now() - outputPathStart;
30
+ async function cleanupTmpDir() {
31
+ // Feel a certain way about leaving things uncleaned, but helps with debugging for now
32
+ // await rm(outputDir, {recursive: true, force: true}).catch(logCleanupFailure)
33
+ }
34
+ try {
35
+ const viteStart = performance.now();
36
+ const result = await viteBuild({
37
+ root: fnRootDir,
38
+ logLevel: 'silent',
39
+ build: {
40
+ target: 'node20',
41
+ outDir: outputDir,
42
+ emptyOutDir: false,
43
+ minify: false,
44
+ sourcemap: true,
45
+ ssr: true,
46
+ rollupOptions: {
47
+ input: entry,
48
+ output: {
49
+ format: 'esm',
50
+ entryFileNames: getOutputFilename(entry),
51
+ },
52
+ },
53
+ },
54
+ ssr: {
55
+ noExternal: true,
56
+ resolve: {
57
+ conditions: ['sanity-function', 'vite'],
58
+ },
59
+ },
60
+ plugins: [tsConfigPaths()],
61
+ });
62
+ timings['bundle:build'] = performance.now() - viteStart;
63
+ const verifyStart = performance.now();
64
+ await verifyHandler(result);
65
+ timings['bundle:verify'] = performance.now() - verifyStart;
66
+ const pkgStart = performance.now();
67
+ await writeBundledPackageJson(entryDir, outputFile);
68
+ timings['bundle:writePackage'] = performance.now() - pkgStart;
69
+ const cleanupStart = performance.now();
70
+ await cleanupSourceMaps(sourcePath, outputDir);
71
+ timings['bundle:cleanupMaps'] = performance.now() - cleanupStart;
72
+ timings.bundle = performance.now() - bundleStart;
73
+ return {
74
+ type: 'success',
75
+ outputDir,
76
+ warnings: [],
77
+ cleanup: cleanupTmpDir,
78
+ timings,
79
+ };
80
+ }
81
+ catch (err) {
82
+ await cleanupTmpDir();
83
+ throw new Error(`Bundling of function failed: ${err instanceof Error ? err.message : err}`, {
84
+ cause: err,
85
+ });
86
+ }
87
+ }
88
+ async function writeBundledPackageJson(inputDir, outputFilePath) {
89
+ const baseName = path.basename(outputFilePath);
90
+ let original;
91
+ try {
92
+ const pkgJsonPath = path.join(inputDir, 'package.json');
93
+ original = JSON.parse(await readFile(pkgJsonPath, 'utf-8'));
94
+ }
95
+ catch {
96
+ original = undefined;
97
+ }
98
+ const bundled = {
99
+ // One could argue that we should strip this down significantly.
100
+ // Theoretically though, a function may reach into it to get dependency versions
101
+ // and whatnot, so maybe it makes more sense to keep it as close to the original
102
+ // as possible?
103
+ ...original,
104
+ main: baseName, // This should never be the input file, always the built output name
105
+ type: 'module', // We explicitly create ESM output
106
+ };
107
+ const pkgJsonOutputPath = path.join(path.dirname(outputFilePath), 'package.json');
108
+ await writeFile(pkgJsonOutputPath, JSON.stringify(bundled, null, 2));
109
+ }
110
+ async function getBundleOutputPath(entryDir, fnName) {
111
+ const tmpPath = path.resolve(entryDir, '.build', `function-${fnName}`);
112
+ await rm(tmpPath, { recursive: true, force: true }).catch(logCleanupFailure);
113
+ await mkdir(tmpPath, { recursive: true });
114
+ return tmpPath;
115
+ }
116
+ /**
117
+ * Minor convenience/niceness to keep the same input filename, but change the extension
118
+ */
119
+ function getOutputFilename(entryFileName) {
120
+ const baseName = path.basename(entryFileName, path.extname(entryFileName));
121
+ return baseName ? `${baseName}.js` : 'index.js';
122
+ }
123
+ function logCleanupFailure(err) {
124
+ console.warn(`[warn] Failed to clean up temporary files: ${err instanceof Error ? err.message : err}`);
125
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * "Clean up" source maps by removing absolute paths and making paths relative to the
3
+ * _input_ (eg source) rather than the _output_ (eg bundle) directory. Note that this
4
+ * process is not critical since the source content is inlined, but it helps with
5
+ * debugging to have (approximate) paths to the original source files.
6
+ *
7
+ * @param inputDir - The directory where the source files are located
8
+ * @param outputDir - The directory where the bundled files are located
9
+ */
10
+ export declare function cleanupSourceMaps(inputDir: string, outputDir: string): Promise<void>;
@@ -0,0 +1,53 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { isAbsolute, join, relative } from 'node:path';
3
+ /**
4
+ * "Clean up" source maps by removing absolute paths and making paths relative to the
5
+ * _input_ (eg source) rather than the _output_ (eg bundle) directory. Note that this
6
+ * process is not critical since the source content is inlined, but it helps with
7
+ * debugging to have (approximate) paths to the original source files.
8
+ *
9
+ * @param inputDir - The directory where the source files are located
10
+ * @param outputDir - The directory where the bundled files are located
11
+ */
12
+ export async function cleanupSourceMaps(inputDir, outputDir) {
13
+ const entries = await fs.readdir(outputDir, { withFileTypes: true });
14
+ const sourceMaps = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.map'));
15
+ for (const entry of sourceMaps) {
16
+ const filePath = join(outputDir, entry.name);
17
+ let raw;
18
+ try {
19
+ raw = await fs.readFile(filePath, 'utf8');
20
+ }
21
+ catch {
22
+ return;
23
+ }
24
+ let map;
25
+ try {
26
+ const json = JSON.parse(raw);
27
+ map = isRelevantSourceMap(json) ? json : undefined;
28
+ }
29
+ catch {
30
+ return;
31
+ }
32
+ if (!map) {
33
+ return;
34
+ }
35
+ map.sources = map.sources.map((source) => {
36
+ const fullPath = isAbsolute(source) ? source : join(outputDir, source);
37
+ return relative(inputDir, fullPath);
38
+ });
39
+ try {
40
+ await fs.writeFile(filePath, JSON.stringify(map));
41
+ }
42
+ catch {
43
+ // ignore write errors
44
+ }
45
+ }
46
+ }
47
+ function isRelevantSourceMap(map) {
48
+ return (typeof map === 'object' &&
49
+ map !== null &&
50
+ 'sources' in map &&
51
+ Array.isArray(map.sources) &&
52
+ map.sources.every((source) => typeof source === 'string'));
53
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Walks up the directory tree from a starting point to find a given file/directory.
3
+ *
4
+ * @param fileName - The name of the file/directory to find.
5
+ * @param startDir - The directory to start searching from (default is the current working directory).
6
+ * @returns The path to the file if found, otherwise undefined.
7
+ */
8
+ export declare function findUp(fileName: string, startDir?: string): Promise<string | undefined>;
9
+ /**
10
+ * Finds the directory containing a specific file/directory by walking up the directory tree.
11
+ *
12
+ * @param fileName - The name of the file/directory to find.
13
+ * @param startDir - The directory to start searching from (default is the current working directory).
14
+ * @returns The directory containing the file if found, otherwise undefined.
15
+ */
16
+ export declare function findDirUp(fileName: string, startDir?: string): Promise<string | undefined>;
@@ -0,0 +1,39 @@
1
+ import { access } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ /**
4
+ * Walks up the directory tree from a starting point to find a given file/directory.
5
+ *
6
+ * @param fileName - The name of the file/directory to find.
7
+ * @param startDir - The directory to start searching from (default is the current working directory).
8
+ * @returns The path to the file if found, otherwise undefined.
9
+ */
10
+ export async function findUp(fileName, startDir = process.cwd()) {
11
+ let dir = path.resolve(startDir);
12
+ while (true) {
13
+ const candidate = path.join(dir, fileName);
14
+ try {
15
+ await access(candidate);
16
+ return candidate;
17
+ }
18
+ catch {
19
+ const parent = path.dirname(dir);
20
+ if (parent === dir)
21
+ break; // Reached root
22
+ dir = parent;
23
+ }
24
+ }
25
+ return undefined;
26
+ }
27
+ /**
28
+ * Finds the directory containing a specific file/directory by walking up the directory tree.
29
+ *
30
+ * @param fileName - The name of the file/directory to find.
31
+ * @param startDir - The directory to start searching from (default is the current working directory).
32
+ * @returns The directory containing the file if found, otherwise undefined.
33
+ */
34
+ export async function findDirUp(fileName, startDir = process.cwd()) {
35
+ const filePath = await findUp(fileName, startDir);
36
+ if (!filePath)
37
+ return undefined;
38
+ return path.dirname(filePath);
39
+ }
@@ -0,0 +1,2 @@
1
+ import type { build } from 'vite';
2
+ export declare function verifyHandler(result: Awaited<ReturnType<typeof build>>): Promise<void>;
@@ -0,0 +1,13 @@
1
+ export async function verifyHandler(result) {
2
+ if ('close' in result) {
3
+ throw new Error('Incorrect build output, got watcher');
4
+ }
5
+ const outputs = (Array.isArray(result) ? result : [result]).flatMap(({ output }) => output);
6
+ const bundledIndex = outputs.find((output) => output.type === 'chunk' && output.isEntry && output.name === 'index');
7
+ if (!bundledIndex || bundledIndex.type !== 'chunk') {
8
+ throw new Error('Unexpected build output, no bundled index found');
9
+ }
10
+ if (!bundledIndex.exports.includes('handler')) {
11
+ throw new Error('Unexpected build output, no `handler` export found');
12
+ }
13
+ }
@@ -1,16 +1,18 @@
1
1
  import {existsSync, statSync} from 'node:fs'
2
- import {join} from 'node:path'
2
+ import {isAbsolute, join} from 'node:path'
3
3
  import process from 'node:process'
4
4
 
5
5
  export function getFunctionSource(src) {
6
- if (statSync(src).isDirectory()) {
7
- const indexPath = join(src, 'index.js')
6
+ const pathToCheck = isAbsolute(src) ? src : join(process.cwd(), src)
7
+
8
+ if (statSync(pathToCheck).isDirectory()) {
9
+ const indexPath = join(pathToCheck, 'index.js')
8
10
  if (!existsSync(indexPath)) {
9
- throw Error(`Function directory ${src} has no index.js`)
11
+ throw Error(`Function directory ${pathToCheck} has no index.js`)
10
12
  }
11
13
  return indexPath
12
14
  }
13
- return src
15
+ return pathToCheck
14
16
  }
15
17
 
16
18
  // Start when payload data arrives from parent process
@@ -28,7 +30,7 @@ process.on('message', async (data) => {
28
30
  let json = null
29
31
 
30
32
  // Import the function code
31
- const {handler} = await import(getFunctionSource(join(process.cwd(), srcPath)))
33
+ const {handler} = await import(getFunctionSource(srcPath))
32
34
 
33
35
  // backup stdout
34
36
  const originalStdoutWrite = process.stdout.write.bind(process.stdout)
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Resolves the source path to an executable entry file path.
3
+ *
4
+ * If the source path is a directory, it looks for `package.json#main`, then `index.ts`, then `index.js`.
5
+ *
6
+ * @param srcPath - The source path (can be a file or directory).
7
+ * @param displayName - Optional display name for the function, used in error messages.
8
+ * @returns The absolute path to the entry file.
9
+ * @throws If the entry file cannot be determined.
10
+ */
11
+ export declare function findFunctionEntryPoint(srcPath: string, displayName?: string): Promise<string>;
@@ -0,0 +1,75 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import { cwd } from 'node:process';
4
+ /**
5
+ * Resolves the source path to an executable entry file path.
6
+ *
7
+ * If the source path is a directory, it looks for `package.json#main`, then `index.ts`, then `index.js`.
8
+ *
9
+ * @param srcPath - The source path (can be a file or directory).
10
+ * @param displayName - Optional display name for the function, used in error messages.
11
+ * @returns The absolute path to the entry file.
12
+ * @throws If the entry file cannot be determined.
13
+ */
14
+ export async function findFunctionEntryPoint(srcPath, displayName) {
15
+ const absolutePath = resolve(cwd(), srcPath);
16
+ let stats;
17
+ try {
18
+ stats = await stat(absolutePath);
19
+ }
20
+ catch (err) {
21
+ throw new Error(`Source path not found or inaccessible: ${srcPath}`, { cause: err });
22
+ }
23
+ if (stats.isFile()) {
24
+ // It's already an entry file path
25
+ return absolutePath;
26
+ }
27
+ if (stats.isDirectory()) {
28
+ // 1. Check package.json#main
29
+ try {
30
+ const pkgJsonPath = join(absolutePath, 'package.json');
31
+ const pkgJsonContent = await readFile(pkgJsonPath, 'utf8');
32
+ const pkgJson = JSON.parse(pkgJsonContent);
33
+ if (pkgJson.main) {
34
+ const mainPath = resolve(absolutePath, pkgJson.main);
35
+ if (await fileExists(mainPath)) {
36
+ return mainPath;
37
+ }
38
+ // If pkgJson.main points to a non-existent file, we continue checking index files
39
+ }
40
+ }
41
+ catch {
42
+ // Ignore errors (missing package.json, invalid JSON, etc.)
43
+ // Consider warning the user on invalid JSON though?
44
+ }
45
+ // 2. Check index.ts
46
+ const indexTs = join(absolutePath, 'index.ts');
47
+ if (await fileExists(indexTs)) {
48
+ return indexTs;
49
+ }
50
+ // 3. Check index.js
51
+ const indexJs = join(absolutePath, 'index.js');
52
+ if (await fileExists(indexJs)) {
53
+ return indexJs;
54
+ }
55
+ const nameHint = displayName ? ` for function "${displayName}"` : '';
56
+ throw new Error(`Could not determine entry file${nameHint} in directory: ${srcPath}. Looked for package.json#main, index.ts, index.js.`);
57
+ }
58
+ // Should not happen if stat succeeded, but defensively handle
59
+ throw new Error(`Source path is neither a file nor a directory: ${srcPath}`);
60
+ }
61
+ /**
62
+ * Checks if a file exists and is a file.
63
+ */
64
+ async function fileExists(filePath) {
65
+ try {
66
+ const stats = await stat(filePath);
67
+ return stats.isFile();
68
+ }
69
+ catch (err) {
70
+ if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
71
+ return false;
72
+ }
73
+ throw err; // Re-throw other errors
74
+ }
75
+ }
@@ -0,0 +1,2 @@
1
+ import type { LocalFunctionResource } from '../types.js';
2
+ export declare function shouldBundleFunction(resource: LocalFunctionResource): Promise<boolean>;
@@ -0,0 +1,23 @@
1
+ import { findFunctionEntryPoint } from './find-entry-point.js';
2
+ export async function shouldBundleFunction(resource) {
3
+ // 1. Explicit configuration takes precedence
4
+ if (typeof resource.bundle === 'boolean') {
5
+ return resource.bundle;
6
+ }
7
+ if (!resource.src) {
8
+ // Cannot determine without a source path
9
+ return false;
10
+ }
11
+ try {
12
+ // 2. Find the actual entry point
13
+ const entryPoint = await findFunctionEntryPoint(resource.src, resource.displayName ?? resource.name);
14
+ // 3. Check if the resolved entry point is a TypeScript file
15
+ return entryPoint.endsWith('.ts');
16
+ }
17
+ catch (err) {
18
+ // If we cannot find the entry point, we cannot determine if it's TS.
19
+ // Log a warning and default to false (don't bundle).
20
+ console.warn(`[warn] Could not determine entry point for function "${resource.displayName ?? resource.name}" while checking if bundling is needed: ${err instanceof Error ? err.message : err}`);
21
+ return false;
22
+ }
23
+ }
@@ -1,3 +1,3 @@
1
- import type { InvocationResponse, InvokeContextOptions } from './types.js';
1
+ import type { InvocationResponse, InvokeContextOptions, LocalFunctionResource } from './types.js';
2
2
  export declare function sanitizeLogs(logs: string): string;
3
- export default function invoke(srcPath: string, data: null | object, context: InvokeContextOptions, timeout?: number): Promise<InvocationResponse>;
3
+ export default function invoke(resource: LocalFunctionResource, data: Record<string, unknown> | null, context: InvokeContextOptions, timeout?: number): Promise<InvocationResponse>;
@@ -1,42 +1,80 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { performance } from 'node:perf_hooks';
2
3
  import { cwd } from 'node:process';
3
4
  import { setTimeout } from 'node:timers';
4
5
  import config from '../config.js';
6
+ import { bundleFunction } from './bundle/bundle-function.js';
7
+ import { findFunctionEntryPoint } from './functions/find-entry-point.js';
8
+ import { shouldBundleFunction } from './functions/should-bundle.js';
5
9
  function getChildProcessWrapperPath() {
6
10
  return new URL('./child-process-wrapper.js', import.meta.url).pathname;
7
11
  }
8
12
  export function sanitizeLogs(logs) {
9
13
  return logs.replace(/([a-zA-Z0-9]{10})[a-zA-Z0-9]{65,}/g, '$1**********');
10
14
  }
11
- export default async function invoke(srcPath, data, context, timeout = 5) {
15
+ export default async function invoke(resource, data, context, timeout = 5) {
16
+ if (!resource.src) {
17
+ throw new Error(`Function resource "${resource.name}" is missing the 'src' property.`);
18
+ }
19
+ let cleanupBundle = async () => { };
20
+ let functionPath = '';
21
+ let bundleTimings = undefined;
22
+ if (await shouldBundleFunction(resource)) {
23
+ const bundleResult = await bundleFunction(resource);
24
+ functionPath = await findFunctionEntryPoint(bundleResult.outputDir);
25
+ bundleTimings = bundleResult.timings;
26
+ cleanupBundle = bundleResult.cleanup;
27
+ }
28
+ else {
29
+ functionPath = await findFunctionEntryPoint(resource.src, resource.displayName ?? resource.name);
30
+ }
12
31
  return new Promise((resolve, reject) => {
13
32
  let child;
14
33
  let timer;
34
+ let executionStart;
15
35
  function start() {
16
- child = spawn('node', [getChildProcessWrapperPath()], {
36
+ executionStart = performance.now();
37
+ child = spawn('node', ['--enable-source-maps', getChildProcessWrapperPath()], {
17
38
  cwd: cwd(),
18
39
  stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
19
40
  });
20
- // Note: start a timeout so child process doesn't run forever
21
41
  child.on('message', (data) => {
42
+ const executionTimeMs = performance.now() - executionStart;
22
43
  const { json, logs } = JSON.parse(data.toString());
23
44
  shutdown();
24
- resolve({ json, logs: sanitizeLogs(logs), error: '' });
45
+ resolve({
46
+ json,
47
+ logs: sanitizeLogs(logs),
48
+ error: undefined,
49
+ timings: {
50
+ ...bundleTimings,
51
+ execute: executionTimeMs,
52
+ },
53
+ });
25
54
  });
26
55
  child.on('error', (error) => {
56
+ shutdown();
27
57
  reject(new Error(`encountered error ${error.message}`));
28
58
  });
29
59
  child.on('exit', (code) => {
60
+ const executionTimeMs = performance.now() - executionStart;
30
61
  shutdown();
31
62
  if (code !== 0) {
32
63
  reject(new Error(`exited with code ${code}`));
33
64
  }
34
65
  else {
35
- resolve({ json: {}, logs: '', error: '' });
66
+ resolve({
67
+ json: {},
68
+ logs: '',
69
+ error: undefined,
70
+ timings: {
71
+ ...bundleTimings,
72
+ execute: executionTimeMs,
73
+ },
74
+ });
36
75
  }
37
76
  });
38
77
  timer = setTimeout(() => {
39
- // timedOut = true
40
78
  shutdown();
41
79
  reject(new Error(`Timed out after hitting its ${timeout}s timeout!`));
42
80
  }, timeout * 1000);
@@ -51,11 +89,14 @@ export default async function invoke(srcPath, data, context, timeout = 5) {
51
89
  },
52
90
  },
53
91
  };
54
- child.send(JSON.stringify({ srcPath, payload }, null, 2));
92
+ child.send(JSON.stringify({ srcPath: functionPath, payload }, null, 2));
55
93
  }
56
94
  function shutdown() {
57
95
  clearTimeout(timer);
58
- child.kill();
96
+ if (child && !child.killed) {
97
+ child.kill();
98
+ }
99
+ cleanupBundle().catch((err) => console.warn('Bundle cleanup failed:', err));
59
100
  }
60
101
  start();
61
102
  });
@@ -0,0 +1 @@
1
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
@@ -0,0 +1,3 @@
1
+ export function isRecord(value) {
2
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3
+ }
@@ -0,0 +1 @@
1
+ export declare function parseJsonObject(jsonString: string): null | Record<string, unknown>;
@@ -0,0 +1,10 @@
1
+ import { isRecord } from './is-record.js';
2
+ export function parseJsonObject(jsonString) {
3
+ try {
4
+ const o = JSON.parse(jsonString);
5
+ return isRecord(o) ? o : null;
6
+ }
7
+ catch {
8
+ return null;
9
+ }
10
+ }
@@ -12,7 +12,7 @@ export interface LocalBlueprint {
12
12
  /** @link https://github.com/sanity-io/blueprints-rfc/blob/main/readme.md#outputs */
13
13
  outputs?: Array<Record<string, unknown>>;
14
14
  /** @link https://github.com/sanity-io/blueprints-rfc/blob/main/readme.md#resources */
15
- resources?: Array<LocalResource>;
15
+ resources?: Array<LocalResource | LocalFunctionResource>;
16
16
  /** @link https://github.com/sanity-io/blueprints-rfc/blob/main/readme.md#parameters */
17
17
  parameters?: Array<{
18
18
  name: string;
@@ -51,6 +51,7 @@ export declare function isLocalFunctionResource(r: LocalResource): r is LocalFun
51
51
  /** @internal */
52
52
  export interface LocalFunctionResource extends LocalResource {
53
53
  src?: string;
54
+ bundle?: boolean;
54
55
  memory?: number;
55
56
  timeout?: number;
56
57
  env?: Record<string, string>;
@@ -115,6 +116,7 @@ export interface InvocationResponse {
115
116
  error: undefined | unknown;
116
117
  json: object | undefined;
117
118
  logs: string | undefined;
119
+ timings?: Record<string, number>;
118
120
  }
119
121
  /** @internal */
120
122
  export interface BlueprintLog {
@@ -769,5 +769,5 @@
769
769
  ]
770
770
  }
771
771
  },
772
- "version": "4.4.0"
772
+ "version": "4.5.0"
773
773
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sanity/runtime-cli",
3
3
  "description": "Sanity's Runtime CLI for Blueprints and Functions",
4
- "version": "4.4.0",
4
+ "version": "4.5.0",
5
5
  "author": "Sanity Runtime Team",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -47,6 +47,7 @@
47
47
  "postbuild": "oclif manifest && oclif readme",
48
48
  "build:ts": "shx rm -rf dist *.tsbuildinfo && tsc -b",
49
49
  "build:static": "npm run copy:wrapper && npm run copy:server",
50
+ "clean": "shx rm -rf dist oclif.manifest.json",
50
51
  "copy:server": "shx cp -r ./src/server/static ./dist/server",
51
52
  "copy:wrapper": "shx cp ./src/utils/child-process-wrapper.js ./dist/utils/child-process-wrapper.js",
52
53
  "lint": "biome ci",
@@ -68,6 +69,8 @@
68
69
  "eventsource": "^3.0.6",
69
70
  "inquirer": "^12.5.2",
70
71
  "mime-types": "^3.0.1",
72
+ "vite": "^6.3.3",
73
+ "vite-tsconfig-paths": "^5.1.4",
71
74
  "xdg-basedir": "^5.1.0",
72
75
  "yocto-spinner": "^0.2.1"
73
76
  },
@@ -1 +0,0 @@
1
- export default function isJson(jsonString: string): null | object;
@@ -1,12 +0,0 @@
1
- export default function isJson(jsonString) {
2
- try {
3
- const o = JSON.parse(jsonString);
4
- if (o && typeof o === 'object') {
5
- return o;
6
- }
7
- }
8
- catch {
9
- return null;
10
- }
11
- return null;
12
- }