@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.
- package/dist/commands/app/dev.d.ts +1 -0
- package/dist/commands/app/dev.js +26 -2
- package/dist/commands/create/component.d.ts +22 -0
- package/dist/commands/create/component.js +129 -0
- package/dist/commands/create/index.js +5 -1
- package/dist/commands/create/tests.js +11 -2
- package/dist/commands/inspect/extensions.js +3 -3
- package/dist/lib/apps/app-config.d.ts +15 -0
- package/dist/lib/apps/app-config.js +99 -30
- package/dist/lib/apps/component-extension.d.ts +23 -0
- package/dist/lib/apps/component-extension.js +60 -0
- package/dist/lib/apps/components.d.ts +32 -0
- package/dist/lib/apps/components.js +81 -0
- package/dist/lib/apps/frontend-scaffold.js +1 -1
- package/dist/lib/apps/index.d.ts +1 -0
- package/dist/lib/apps/index.js +5 -3
- package/dist/lib/bundle.d.ts +21 -1
- package/dist/lib/bundle.js +88 -5
- package/dist/lib/create/component.d.ts +22 -0
- package/dist/lib/create/component.js +194 -0
- package/dist/lib/create/content.d.ts +1 -1
- package/dist/lib/create/setting.d.ts +1 -1
- package/dist/lib/create/tests/templates/index.d.ts +0 -3
- package/dist/lib/create/tests/templates/index.js +0 -3
- package/dist/lib/create/tests/templates/mock-request.js +4 -10
- package/dist/lib/create/tests/templates/tsconfig.js +1 -1
- package/dist/lib/create/tests/templates/vitest-config.d.ts +1 -2
- package/dist/lib/create/tests/templates/vitest-config.js +17 -113
- package/dist/lib/create/tests/types.d.ts +2 -0
- package/dist/lib/create/tests.js +64 -12
- package/dist/lib/function-source-analysis.d.ts +11 -0
- package/dist/lib/function-source-analysis.js +33 -0
- package/dist/lib/inspect/extensions.d.ts +8 -1
- package/dist/lib/inspect/extensions.js +12 -2
- package/dist/lib/swell-function-wrapper.js +31 -0
- package/dist/push-app-command.js +11 -0
- package/oclif.manifest.json +82 -3
- package/package.json +3 -1
- package/dist/lib/create/tests/templates/env-dts.d.ts +0 -1
- package/dist/lib/create/tests/templates/env-dts.js +0 -15
- package/dist/lib/create/tests/templates/integration-test.d.ts +0 -1
- package/dist/lib/create/tests/templates/integration-test.js +0 -19
- package/dist/lib/create/tests/templates/swell-client.d.ts +0 -1
- package/dist/lib/create/tests/templates/swell-client.js +0 -128
package/dist/commands/app/dev.js
CHANGED
|
@@ -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 &&
|
|
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
|
|
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 ||
|
|
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
|
|
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
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
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.
|
|
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>;
|