@swell/cli 2.9.26 → 2.9.28

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 (44) hide show
  1. package/dist/commands/app/dev.d.ts +1 -0
  2. package/dist/commands/app/dev.js +26 -2
  3. package/dist/commands/create/component.d.ts +22 -0
  4. package/dist/commands/create/component.js +129 -0
  5. package/dist/commands/create/index.js +5 -1
  6. package/dist/commands/create/tests.js +11 -2
  7. package/dist/commands/inspect/extensions.js +3 -3
  8. package/dist/lib/apps/app-config.d.ts +15 -0
  9. package/dist/lib/apps/app-config.js +99 -30
  10. package/dist/lib/apps/component-extension.d.ts +23 -0
  11. package/dist/lib/apps/component-extension.js +60 -0
  12. package/dist/lib/apps/components.d.ts +32 -0
  13. package/dist/lib/apps/components.js +81 -0
  14. package/dist/lib/apps/frontend-scaffold.js +1 -1
  15. package/dist/lib/apps/index.d.ts +1 -0
  16. package/dist/lib/apps/index.js +5 -3
  17. package/dist/lib/bundle.d.ts +21 -1
  18. package/dist/lib/bundle.js +88 -5
  19. package/dist/lib/create/component.d.ts +22 -0
  20. package/dist/lib/create/component.js +194 -0
  21. package/dist/lib/create/content.d.ts +1 -1
  22. package/dist/lib/create/setting.d.ts +1 -1
  23. package/dist/lib/create/tests/templates/index.d.ts +0 -3
  24. package/dist/lib/create/tests/templates/index.js +0 -3
  25. package/dist/lib/create/tests/templates/mock-request.js +4 -10
  26. package/dist/lib/create/tests/templates/tsconfig.js +1 -1
  27. package/dist/lib/create/tests/templates/vitest-config.d.ts +1 -2
  28. package/dist/lib/create/tests/templates/vitest-config.js +17 -113
  29. package/dist/lib/create/tests/types.d.ts +2 -0
  30. package/dist/lib/create/tests.js +64 -12
  31. package/dist/lib/function-source-analysis.d.ts +11 -0
  32. package/dist/lib/function-source-analysis.js +33 -0
  33. package/dist/lib/inspect/extensions.d.ts +8 -1
  34. package/dist/lib/inspect/extensions.js +12 -2
  35. package/dist/lib/swell-function-wrapper.js +31 -0
  36. package/dist/push-app-command.js +11 -0
  37. package/oclif.manifest.json +82 -3
  38. package/package.json +3 -1
  39. package/dist/lib/create/tests/templates/env-dts.d.ts +0 -1
  40. package/dist/lib/create/tests/templates/env-dts.js +0 -15
  41. package/dist/lib/create/tests/templates/integration-test.d.ts +0 -1
  42. package/dist/lib/create/tests/templates/integration-test.js +0 -19
  43. package/dist/lib/create/tests/templates/swell-client.d.ts +0 -1
  44. package/dist/lib/create/tests/templates/swell-client.js +0 -128
@@ -39,5 +39,6 @@ export default class AppDev extends PushAppCommand {
39
39
  private logFunction;
40
40
  private onChangeFunctionWatcher;
41
41
  private startAppFunctionServer;
42
+ private logComponentModules;
42
43
  private startFunctionServers;
43
44
  }
@@ -7,6 +7,7 @@ import * as http from 'node:http';
7
7
  import * as os from 'node:os';
8
8
  import * as path from 'node:path';
9
9
  import ora from 'ora';
10
+ import { COMPONENT_DEV_PATH, listRootComponentFiles, serveComponentModule, } from '../../lib/apps/components.js';
10
11
  import { ConfigType, allConfigFilesInDir, appConfigFromFile, } from '../../lib/apps/index.js';
11
12
  import { bundleFunction } from '../../lib/bundle.js';
12
13
  import { FUNCTION_COMPATIBILITY_DATE, getLocalFrontendHost, } from '../../lib/constants.js';
@@ -194,6 +195,10 @@ export default class AppDev extends PushAppCommand {
194
195
  res.writeHead(status);
195
196
  res.end(message);
196
197
  };
198
+ if (url.pathname.startsWith(COMPONENT_DEV_PATH)) {
199
+ serveComponentModule(this.appPath, url.pathname, res).catch((error) => fail(500, error.message));
200
+ return;
201
+ }
197
202
  if (this.functionPorts.has(functionName)) {
198
203
  const targetPort = this.functionPorts.get(functionName);
199
204
  // Log when response is finished, method, status, and time to execute by proxy
@@ -443,15 +448,26 @@ ENVIRONMENT = "development"
443
448
  async startAppFunctionServer(spinner, serverPort) {
444
449
  // Get all functions in this app
445
450
  const functions = await this.getAppFunctions();
451
+ const components = await listRootComponentFiles(this.appPath);
446
452
  const projectType = await this.getFrontendProjectType(false);
447
453
  const shouldLogPreviewUrl = this.app.type === 'storefront' && !projectType;
448
454
  if (functions.length === 0) {
449
- if (!projectType && this.app.type !== 'storefront') {
455
+ if (!projectType &&
456
+ this.app.type !== 'storefront' &&
457
+ components.length === 0) {
450
458
  spinner.stop();
451
- this.log(`No functions or frontend detected. Exiting.\n`);
459
+ this.log(`No functions, components or frontend detected. Exiting.\n`);
452
460
  await this.cleanupOnExit();
453
461
  return;
454
462
  }
463
+ // Components are pushed again when they change; the tmp directory lets
464
+ // a function added while dev runs start like it does in the full mode
465
+ if (components.length > 0) {
466
+ await this.createTmpDirectory();
467
+ this.watchForChanges({
468
+ onChange: this.onChangeFunctionWatcher.bind(this),
469
+ });
470
+ }
455
471
  // Create routing server to proxy requests to frontend even without functions
456
472
  await this.createFunctionRouter(serverPort);
457
473
  if (shouldLogPreviewUrl) {
@@ -461,6 +477,7 @@ ENVIRONMENT = "development"
461
477
  else {
462
478
  spinner.stop();
463
479
  }
480
+ this.logComponentModules(components, serverPort);
464
481
  return;
465
482
  }
466
483
  // Create TMP directory for wrangler configs and bundled functions
@@ -480,6 +497,13 @@ ENVIRONMENT = "development"
480
497
  if (shouldLogPreviewUrl) {
481
498
  this.logLocalPreviewUrl();
482
499
  }
500
+ this.logComponentModules(components, serverPort);
501
+ }
502
+ logComponentModules(components, serverPort) {
503
+ if (components.length === 0) {
504
+ return;
505
+ }
506
+ this.log(`${style.success('→')} Component modules at: http://localhost:${serverPort}${COMPONENT_DEV_PATH}<Name>.js\n`);
483
507
  }
484
508
  async startFunctionServers(functions) {
485
509
  const functionStatus = new Map();
@@ -0,0 +1,22 @@
1
+ import { CreateConfigCommand } from '../../create-config-command.js';
2
+ import { HelpMeta } from '../../help/types.js';
3
+ import { ConfigType } from '../../lib/apps/index.js';
4
+ export default class CreateComponent extends CreateConfigCommand {
5
+ static args: {
6
+ name: import("@oclif/core/lib/interfaces/parser.js").Arg<string, Record<string, unknown>>;
7
+ };
8
+ static description: string;
9
+ static examples: string[];
10
+ static helpMeta: HelpMeta;
11
+ static flags: {
12
+ description: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
13
+ extension: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
14
+ overwrite: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
15
+ target: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string[] | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
16
+ yes: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
17
+ };
18
+ static summary: string;
19
+ createType: ConfigType;
20
+ private checkConfig;
21
+ run(): Promise<void>;
22
+ }
@@ -0,0 +1,129 @@
1
+ import { input } from '@inquirer/prompts';
2
+ import { Args, Flags } from '@oclif/core';
3
+ import * as fs from 'node:fs/promises';
4
+ import * as path from 'node:path';
5
+ import { CreateConfigCommand } from '../../create-config-command.js';
6
+ import { resolveComponentValues, } from '../../lib/apps/component-extension.js';
7
+ import { listRootComponentFiles } from '../../lib/apps/components.js';
8
+ import { ConfigType } from '../../lib/apps/index.js';
9
+ import { getComponentTemplate, setupComponentSupport, toComponentName, } from '../../lib/create/component.js';
10
+ import { readStaticConfig } from '../../lib/function-source-analysis.js';
11
+ export default class CreateComponent extends CreateConfigCommand {
12
+ static args = {
13
+ name: Args.string({
14
+ default: '',
15
+ description: 'Component name (e.g., ColorPicker)',
16
+ }),
17
+ };
18
+ static description = 'Create an app component in the components folder.';
19
+ static examples = [
20
+ '$ swell create component',
21
+ '$ swell create component ColorPicker -d "Brand color picker" -y',
22
+ '$ swell create component MyComponent --extension <extension-id> --target <target-id> -y',
23
+ ];
24
+ static helpMeta = {
25
+ usageDirect: '<name> [-d <description>] [--extension <extension-id>] [--target <target-id>...] -y',
26
+ };
27
+ static flags = {
28
+ description: Flags.string({
29
+ char: 'd',
30
+ default: '',
31
+ description: 'Description',
32
+ }),
33
+ extension: Flags.string({
34
+ description: 'Extension id from swell.json: makes the component the UI of that extension',
35
+ }),
36
+ overwrite: Flags.boolean({
37
+ default: false,
38
+ description: 'Overwrite existing file',
39
+ }),
40
+ target: Flags.string({
41
+ description: 'Target id where the component renders; repeat for more targets',
42
+ multiple: true,
43
+ }),
44
+ yes: Flags.boolean({
45
+ char: 'y',
46
+ description: 'Skip prompts, require all arguments',
47
+ }),
48
+ };
49
+ static summary = 'Create an app component in the components folder.';
50
+ createType = ConfigType.COMPONENT;
51
+ // The same rules as push, so a scaffold that would fail to push is refused here
52
+ async checkConfig(componentName, extension, targets) {
53
+ const configured = this.swellConfig.get('extensions');
54
+ const extensions = Array.isArray(configured)
55
+ ? configured
56
+ : [];
57
+ const files = await listRootComponentFiles(this.appPath);
58
+ const siblings = await Promise.all(files.map(async (file) => {
59
+ const source = await fs
60
+ .readFile(path.join(this.appPath, file), 'utf8')
61
+ .catch(() => '');
62
+ return {
63
+ config: readStaticConfig(source, file).config,
64
+ name: path.basename(file).replace(/\.[^.]+$/, ''),
65
+ };
66
+ }));
67
+ try {
68
+ resolveComponentValues(componentName, {
69
+ ...(extension && { extension: { id: extension } }),
70
+ ...(targets.length > 0 && { targets: targets.map((id) => ({ id })) }),
71
+ }, extensions, siblings);
72
+ }
73
+ catch (error) {
74
+ this.error(error.message, { exit: 1 });
75
+ }
76
+ }
77
+ async run() {
78
+ const { args, flags } = await this.parse(CreateComponent);
79
+ const { description: descriptionFlag, extension, overwrite, target: targets = [], yes, } = flags;
80
+ let name = args.name;
81
+ let description = descriptionFlag;
82
+ if (yes) {
83
+ if (!name) {
84
+ this.error('Missing required argument for non-interactive mode: NAME\n\nExample: swell create component ColorPicker -y', { exit: 1 });
85
+ }
86
+ }
87
+ else {
88
+ if (!name) {
89
+ name = await input({
90
+ default: 'MyComponent',
91
+ message: 'Component name',
92
+ });
93
+ }
94
+ if (!description) {
95
+ description = await input({
96
+ message: 'Describe what the component does',
97
+ });
98
+ }
99
+ }
100
+ const componentName = toComponentName(name);
101
+ if (!componentName) {
102
+ this.error(`Invalid component name: ${name}`, { exit: 1 });
103
+ }
104
+ if (extension || targets.length > 0) {
105
+ await this.checkConfig(componentName, extension, targets);
106
+ }
107
+ const created = await this.createFile({
108
+ extension: 'tsx',
109
+ fileBody: getComponentTemplate(componentName, description, {
110
+ extension,
111
+ targets,
112
+ }),
113
+ fileName: componentName,
114
+ }, overwrite, !yes);
115
+ if (!created) {
116
+ return;
117
+ }
118
+ const { changed, dependenciesAdded, warnings } = await setupComponentSupport(this.appPath);
119
+ if (changed.length > 0) {
120
+ const install = dependenciesAdded
121
+ ? ' Run `npm install` to install preact and @swell/apps-sdk.'
122
+ : '';
123
+ this.log(`Updated ${changed.join(', ')}.${install}`);
124
+ }
125
+ for (const warning of warnings) {
126
+ this.warn(warning);
127
+ }
128
+ }
129
+ }
@@ -1,7 +1,7 @@
1
1
  import { select } from '@inquirer/prompts';
2
2
  import { Command } from '@oclif/core';
3
3
  export default class Create extends Command {
4
- static description = 'Scaffold apps, models, functions, and notifications.';
4
+ static description = 'Scaffold apps, models, functions, components, and notifications.';
5
5
  static examples = ['<%= config.bin %> <%= command.id %>'];
6
6
  async run() {
7
7
  const createTarget = await select({
@@ -18,6 +18,10 @@ export default class Create extends Command {
18
18
  name: 'Content',
19
19
  value: 'content',
20
20
  },
21
+ {
22
+ name: 'Component',
23
+ value: 'component',
24
+ },
21
25
  {
22
26
  name: 'Function',
23
27
  value: 'function',
@@ -4,7 +4,7 @@ import { toAppId } from '../../lib/create/index.js';
4
4
  import { createTestsScaffold, } from '../../lib/create/tests.js';
5
5
  import { detectPackageManager, getPackageManagerCommands, } from '../../lib/package-manager.js';
6
6
  export default class CreateTests extends AppCommand {
7
- static description = 'Initialize a vitest + Cloudflare Workers test setup that reuses swell-cli authentication.';
7
+ static description = 'Initialize a vitest + Cloudflare Workers test setup for app functions.';
8
8
  static examples = [
9
9
  '$ swell create tests',
10
10
  '$ swell create tests --overwrite',
@@ -43,7 +43,9 @@ export default class CreateTests extends AppCommand {
43
43
  this.logScaffoldResult(result, pm);
44
44
  }
45
45
  logScaffoldResult(result, pm) {
46
- const hasChanges = result.createdFiles.length > 0 || result.packageJsonUpdated;
46
+ const hasChanges = result.createdFiles.length > 0 ||
47
+ result.removedFiles.length > 0 ||
48
+ result.packageJsonUpdated;
47
49
  if (hasChanges) {
48
50
  this.log('');
49
51
  this.log('✓ Created test scaffolding');
@@ -62,6 +64,13 @@ export default class CreateTests extends AppCommand {
62
64
  this.log(` ${file}`);
63
65
  }
64
66
  }
67
+ if (result.removedFiles.length > 0) {
68
+ this.log('');
69
+ this.log('Removed (no longer part of the test setup):');
70
+ for (const file of result.removedFiles) {
71
+ this.log(` ${file}`);
72
+ }
73
+ }
65
74
  if (result.packageJsonUpdated) {
66
75
  this.log('');
67
76
  this.log('Updated package.json with test scripts and devDependencies.');
@@ -6,7 +6,7 @@ import { resolveInspectScope, ScopeError, } from '../../lib/apps/inspect-scope.j
6
6
  import { HEX24, isObjectId } from '../../lib/apps/object-id.js';
7
7
  import { resolveAppId } from '../../lib/apps/resolve.js';
8
8
  import { default as localConfig } from '../../lib/config.js';
9
- import { buildExtensionDetail, buildOrphanDetail, componentMatchesExtension, diffManifestEntries, formatExtensionKey, functionMatchesExtension, listGroupForApp, listGroupForOrphans, listMetaFor, nextStepLines, orphanListMeta, parseExtensionKey, } from '../../lib/inspect/extensions.js';
9
+ import { buildExtensionDetail, buildOrphanDetail, componentMatchesExtension, diffManifestEntries, formatExtensionKey, componentExtensionId, functionMatchesExtension, listGroupForApp, listGroupForOrphans, listMetaFor, nextStepLines, orphanListMeta, parseExtensionKey, } from '../../lib/inspect/extensions.js';
10
10
  import { renderKeyMetaTable } from '../../lib/inspect/table.js';
11
11
  import { SwellCommand } from '../../swell-command.js';
12
12
  export default class Extensions extends SwellCommand {
@@ -144,7 +144,7 @@ Identifier forms:
144
144
  }
145
145
  }
146
146
  for (const comp of appComponents) {
147
- const ext = comp.values?.extension;
147
+ const ext = componentExtensionId(comp.values);
148
148
  if (!ext)
149
149
  continue;
150
150
  if (declaredIds.has(ext))
@@ -331,7 +331,7 @@ Identifier forms:
331
331
  // but no manifest entry declares it. Surface as the orphan envelope only
332
332
  // when we actually find handlers; otherwise it's a "not found" lookup.
333
333
  const orphanFns = allFunctions.filter((f) => f.extension === extId);
334
- const orphanComps = allComponents.filter((c) => c.values?.extension === extId);
334
+ const orphanComps = allComponents.filter((c) => componentExtensionId(c.values) === extId);
335
335
  if (orphanFns.length === 0 && orphanComps.length === 0) {
336
336
  this.error(`No extension '${extId}' declared by app '${appSlug}', and no handlers reference it. ` +
337
337
  `List available: swell inspect extensions${scope.appSlug ? ` --app=${scope.appSlug}` : ''}`, { exit: 1 });
@@ -50,6 +50,12 @@ export declare abstract class AppConfig {
50
50
  * @returns {Promise<string | undefined>} the hash
51
51
  */
52
52
  protected contentHash(): Promise<string | undefined>;
53
+ /**
54
+ * File hash combined with the app files this file imports, for configs that
55
+ * deploy as a bundle: a change in shared code redeploys them
56
+ * @returns {Promise<string | undefined>} the hash, or undefined until the file compiles
57
+ */
58
+ protected bundleHash(): Promise<string | undefined>;
53
59
  /**
54
60
  * Full path of the app directory this config belongs to
55
61
  * @returns {string} the path
@@ -60,6 +66,11 @@ export declare abstract class AppConfig {
60
66
  postData(): Promise<any>;
61
67
  prepareFileData(): any;
62
68
  abstract hasValues: boolean;
69
+ /**
70
+ * Whether .json files are parsed into values; otherwise they are pushed as raw files
71
+ * @returns {boolean} true to parse
72
+ */
73
+ protected parsesJsonValues(): boolean;
63
74
  /** build the JSON used when making requests (usually push) to the API */
64
75
  protected abstract preparePostData(postData: any): Promise<any>;
65
76
  abstract type: ConfigType;
@@ -104,7 +115,11 @@ export declare class AppConfigFunction extends AppConfig {
104
115
  export declare class AppConfigComponent extends AppConfig {
105
116
  hasValues: boolean;
106
117
  type: ConfigType;
118
+ protected parsesJsonValues(): boolean;
107
119
  isRootComponent(): boolean;
120
+ protected contentHash(): Promise<string | undefined>;
121
+ private manifestExtensions;
122
+ private siblingComponents;
108
123
  preparePostData(postData: any): Promise<any>;
109
124
  }
110
125
  export declare class AppConfigAsset extends AppConfigDefault {
@@ -3,9 +3,11 @@ import isEmpty from 'lodash/isEmpty.js';
3
3
  import { detectFilenameMime } from 'mime-detect';
4
4
  import * as fs from 'node:fs/promises';
5
5
  import * as path from 'node:path';
6
- import { bundleFunction, bundleComponent, bundleWorkflow, getLocalInputs, } from '../bundle.js';
7
- import { analyzeFunctionSource, hasKindDiagnostic, workflowStaticConfigError, } from '../function-source-analysis.js';
8
- import { AllConfigPaths, ConfigType, filePathExistsAsync, hashFile, hashString, } from './index.js';
6
+ import { bundleFunction, bundleComponent, bundleComponentModule, bundleWorkflow, getLocalInputs, } from '../bundle.js';
7
+ import { analyzeFunctionSource, hasKindDiagnostic, readStaticConfig, workflowStaticConfigError, } from '../function-source-analysis.js';
8
+ import { isLegacyComponent, resolveComponentValues, } from './component-extension.js';
9
+ import { listRootComponentFiles } from './components.js';
10
+ import { AllConfigPaths, ConfigType, filePathExistsAsync, getNameFromFilePath, hashFile, hashString, } from './index.js';
9
11
  export class IgnoringFileError extends Error {
10
12
  constructor(message) {
11
13
  super(message);
@@ -95,6 +97,30 @@ export class AppConfig {
95
97
  async contentHash() {
96
98
  return this.fileHash;
97
99
  }
100
+ /**
101
+ * File hash combined with the app files this file imports, for configs that
102
+ * deploy as a bundle: a change in shared code redeploys them
103
+ * @returns {Promise<string | undefined>} the hash, or undefined until the file compiles
104
+ */
105
+ async bundleHash() {
106
+ if (!this.fileHash) {
107
+ return this.fileHash;
108
+ }
109
+ const appRoot = this.appRoot();
110
+ let inputs;
111
+ try {
112
+ inputs = await getLocalInputs(this.appPath, appRoot);
113
+ }
114
+ catch {
115
+ // Unknown until it compiles: push tries it and reports the error
116
+ return undefined;
117
+ }
118
+ const imports = inputs
119
+ .filter((input) => input !== this.filePath)
120
+ .sort()
121
+ .map((input) => hashFile(path.join(appRoot, input), undefined, input));
122
+ return hashString(this.fileHash, ...imports);
123
+ }
98
124
  /**
99
125
  * Full path of the app directory this config belongs to
100
126
  * @returns {string} the path
@@ -166,7 +192,7 @@ export class AppConfig {
166
192
  if (this.fileData === undefined) {
167
193
  return;
168
194
  }
169
- if (this.hasValues && this.filePath.endsWith('.json')) {
195
+ if (this.parsesJsonValues() && this.filePath.endsWith('.json')) {
170
196
  try {
171
197
  this.values = JSON.parse(this.fileData.toString('utf8'));
172
198
  }
@@ -205,6 +231,13 @@ export class AppConfig {
205
231
  ? { $base64: this.fileData.toString('base64') }
206
232
  : this.fileData?.toString('utf8');
207
233
  }
234
+ /**
235
+ * Whether .json files are parsed into values; otherwise they are pushed as raw files
236
+ * @returns {boolean} true to parse
237
+ */
238
+ parsesJsonValues() {
239
+ return this.hasValues;
240
+ }
208
241
  }
209
242
  export class AppConfigDefault extends AppConfig {
210
243
  hasValues = false;
@@ -280,23 +313,7 @@ export class AppConfigFunction extends AppConfig {
280
313
  // A function deploys as a bundle, so the hash includes the local files it
281
314
  // imports: a change in shared code under functions/lib redeploys it
282
315
  async contentHash() {
283
- if (!this.isRootFunction() || !this.fileHash) {
284
- return this.fileHash;
285
- }
286
- const appRoot = this.appRoot();
287
- let inputs;
288
- try {
289
- inputs = await getLocalInputs(this.appPath, appRoot);
290
- }
291
- catch {
292
- // Unknown until it compiles: push tries it and reports the error
293
- return undefined;
294
- }
295
- const imports = inputs
296
- .filter((input) => input !== this.filePath)
297
- .sort()
298
- .map((input) => hashFile(path.join(appRoot, input), undefined, input));
299
- return hashString(this.fileHash, ...imports);
316
+ return this.isRootFunction() ? this.bundleHash() : this.fileHash;
300
317
  }
301
318
  async preparePostData(postData) {
302
319
  if (this.isRootFunction()) {
@@ -352,10 +369,44 @@ export class AppConfigFunction extends AppConfig {
352
369
  export class AppConfigComponent extends AppConfig {
353
370
  hasValues = true;
354
371
  type = ConfigType.COMPONENT;
372
+ // Only .tsx/.jsx files are components, any .json under components/ is shared source
373
+ parsesJsonValues() {
374
+ return false;
375
+ }
355
376
  isRootComponent() {
356
377
  return (this.isRootConfig('components') &&
357
378
  (this.filePath.endsWith('.jsx') || this.filePath.endsWith('.tsx')));
358
379
  }
380
+ // A component deploys as a bundle, so the hash includes the local files it
381
+ // imports: a change in shared code under components/lib redeploys it
382
+ async contentHash() {
383
+ return this.isRootComponent() ? this.bundleHash() : this.fileHash;
384
+ }
385
+ // Read only for components that name an extension, so other apps never need a readable swell.json
386
+ async manifestExtensions() {
387
+ try {
388
+ const manifest = JSON.parse(await fs.readFile(path.join(this.appRoot(), 'swell.json'), 'utf8'));
389
+ return Array.isArray(manifest?.extensions) ? manifest.extensions : [];
390
+ }
391
+ catch (error) {
392
+ throw new Error(`cannot read swell.json: ${error.message}`);
393
+ }
394
+ }
395
+ // Siblings whose config cannot be read are skipped: their own push reports them
396
+ async siblingComponents() {
397
+ const appRoot = this.appRoot();
398
+ const files = await listRootComponentFiles(appRoot);
399
+ return Promise.all(files
400
+ .filter((file) => file !== this.filePath)
401
+ .map(async (file) => {
402
+ const source = await fs
403
+ .readFile(path.join(appRoot, file), 'utf8')
404
+ // A sibling that vanished or was renamed reads as empty and is skipped
405
+ .catch(() => '');
406
+ const { config } = readStaticConfig(source, file);
407
+ return { name: getNameFromFilePath(file), config };
408
+ }));
409
+ }
359
410
  async preparePostData(postData) {
360
411
  if (!this.isRootComponent()) {
361
412
  return postData;
@@ -366,19 +417,37 @@ export class AppConfigComponent extends AppConfig {
366
417
  if (!fileData) {
367
418
  return;
368
419
  }
369
- const { code, config } = await bundleComponent(this.filePath);
370
- if (!config) {
371
- throw new IgnoringFileError('Component must export a `config` object.');
420
+ const source = this.fileData?.toString('utf8') ?? '';
421
+ const { config, errors } = readStaticConfig(source, this.filePath);
422
+ if (errors.length > 0) {
423
+ throw new Error(`config must be static metadata: ${errors.join('; ')}`);
424
+ }
425
+ const name = getNameFromFilePath(this.filePath);
426
+ // Without an object extension only the targets shape is checked, with no manifest read
427
+ const values = config?.extension !== undefined && !isLegacyComponent(config)
428
+ ? resolveComponentValues(name, config, await this.manifestExtensions(), await this.siblingComponents())
429
+ : resolveComponentValues(name, config, [], []);
430
+ // Legacy payment components (string extension) still load through swell.app, which evaluates an IIFE
431
+ if (isLegacyComponent(config)) {
432
+ postData.build_file = {
433
+ content_type: 'application/javascript',
434
+ data: await bundleComponent(this.appPath),
435
+ };
436
+ }
437
+ else {
438
+ const { code } = await bundleComponentModule(this.filePath, this.appRoot());
439
+ // The CDN keeps a file's type only through an extension it maps, and
440
+ // it maps application/javascript but not text/javascript. Frames
441
+ // import the bundle as a module, which browsers refuse as text/plain
442
+ postData.build_file = {
443
+ content_type: 'application/javascript',
444
+ data: code,
445
+ };
372
446
  }
373
- // Save the original file and the bundled version
374
447
  postData.file = {
375
448
  data: fileData,
376
449
  };
377
- postData.build_file = {
378
- content_type: 'application/javascript',
379
- data: code,
380
- };
381
- postData.values = config;
450
+ postData.values = values;
382
451
  }
383
452
  catch (error) {
384
453
  throw new FunctionProcessingError(`Unable to compile component ${this.name}`, error);
@@ -0,0 +1,23 @@
1
+ export interface ManifestExtension {
2
+ id: string;
3
+ type: string;
4
+ }
5
+ export interface SiblingComponent {
6
+ name: string;
7
+ config?: Record<string, unknown>;
8
+ }
9
+ /**
10
+ * Whether the config is the legacy swell.app contract (string extension)
11
+ * @param {object} config static component config
12
+ * @returns {boolean} true for a string extension
13
+ */
14
+ export declare function isLegacyComponent(config: Record<string, unknown> | undefined): boolean;
15
+ /**
16
+ * The values to push for a component's static config
17
+ * @param {string} name component name
18
+ * @param {object} config static component config
19
+ * @param {ManifestExtension[]} extensions extensions of swell.json
20
+ * @param {SiblingComponent[]} siblings the app's other root components
21
+ * @returns {object} values with the extension type added
22
+ */
23
+ export declare function resolveComponentValues(name: string, config: Record<string, unknown> | undefined, extensions: ManifestExtension[], siblings: SiblingComponent[]): Record<string, unknown>;
@@ -0,0 +1,60 @@
1
+ const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
2
+ const TARGETS_SHAPE = 'targets must be a list of objects with an id, like [{ id: "<target id>" }]';
3
+ /**
4
+ * Whether the config is the legacy swell.app contract (string extension)
5
+ * @param {object} config static component config
6
+ * @returns {boolean} true for a string extension
7
+ */
8
+ export function isLegacyComponent(config) {
9
+ return typeof config?.extension === 'string';
10
+ }
11
+ // Target ids and what each target needs are the platform's to check at push
12
+ function checkTargets(config) {
13
+ if (config.targets === undefined) {
14
+ return;
15
+ }
16
+ if (!Array.isArray(config.targets) ||
17
+ !config.targets.every((item) => isRecord(item) && typeof item.id === 'string' && item.id !== '')) {
18
+ throw new Error(TARGETS_SHAPE);
19
+ }
20
+ }
21
+ /**
22
+ * The values to push for a component's static config
23
+ * @param {string} name component name
24
+ * @param {object} config static component config
25
+ * @param {ManifestExtension[]} extensions extensions of swell.json
26
+ * @param {SiblingComponent[]} siblings the app's other root components
27
+ * @returns {object} values with the extension type added
28
+ */
29
+ export function resolveComponentValues(name, config, extensions, siblings) {
30
+ const values = { ...config };
31
+ checkTargets(values);
32
+ const { extension } = values;
33
+ // A string extension is the legacy swell.app contract
34
+ if (extension === undefined || typeof extension === 'string') {
35
+ return values;
36
+ }
37
+ if (!isRecord(extension) ||
38
+ typeof extension.id !== 'string' ||
39
+ !extension.id) {
40
+ throw new Error('extension must be an object with an id, like { id: "card" }');
41
+ }
42
+ const matches = extensions.filter((item) => item.id === extension.id);
43
+ if (matches.length > 1) {
44
+ throw new Error(`extension "${extension.id}" is declared more than once in swell.json`);
45
+ }
46
+ const [declared] = matches;
47
+ if (!declared) {
48
+ throw new Error(`extension "${extension.id}" is not declared in swell.json`);
49
+ }
50
+ const other = siblings.find((sibling) => {
51
+ const siblingExtension = sibling.config?.extension;
52
+ return (sibling.name !== name &&
53
+ isRecord(siblingExtension) &&
54
+ siblingExtension.id === extension.id);
55
+ });
56
+ if (other) {
57
+ throw new Error(`extension "${extension.id}" already has the component ${other.name}`);
58
+ }
59
+ return { ...values, extension: { ...extension, type: declared.type } };
60
+ }
@@ -0,0 +1,32 @@
1
+ /// <reference types="node" resolution-mode="require"/>
2
+ import type * as http from 'node:http';
3
+ /** Path of component modules on the `swell app dev` proxy */
4
+ export declare const COMPONENT_DEV_PATH = "/.swell/components/";
5
+ /**
6
+ * Whether a file is a component entry: a .tsx or .jsx file directly in components/
7
+ * @param {string} file path relative to the app
8
+ * @returns {boolean} true for a root component
9
+ */
10
+ export declare function isRootComponentFile(file: string): boolean;
11
+ /**
12
+ * Component entries of an app
13
+ * @param {string} appPath app directory
14
+ * @returns {Promise<string[]>} paths relative to the app, sorted
15
+ */
16
+ export declare function listRootComponentFiles(appPath: string): Promise<string[]>;
17
+ /**
18
+ * Components to rebuild after a change: root components bundle their shared
19
+ * code, so a change under components/ outside the root rebuilds all of them
20
+ * @param {string} appPath app directory
21
+ * @param {string} changedFile changed file, relative to the app
22
+ * @returns {Promise<string[]>} root components to push again
23
+ */
24
+ export declare function componentsToRebuild(appPath: string, changedFile: string): Promise<string[]>;
25
+ /**
26
+ * Builds and serves `/.swell/components/<Name>.js` for `swell app dev`
27
+ * @param {string} appPath app directory
28
+ * @param {string} pathname request path
29
+ * @param {http.ServerResponse} res response to write
30
+ * @returns {Promise<void>} resolves when the response is written
31
+ */
32
+ export declare function serveComponentModule(appPath: string, pathname: string, res: http.ServerResponse): Promise<void>;