@swell/cli 2.9.21 → 2.9.22

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.
@@ -28,4 +28,5 @@ export default class AppPush extends PushAppCommand {
28
28
  */
29
29
  confirmPushConflicts(conflictsFlag?: string, file?: string): Promise<boolean>;
30
30
  run(): Promise<void>;
31
+ private exitIfFilesFailed;
31
32
  }
@@ -153,6 +153,7 @@ ${Object.values(AllConfigPaths)
153
153
  }
154
154
  this.log();
155
155
  this.logDashboardUrl(flags);
156
+ this.exitIfFilesFailed();
156
157
  return;
157
158
  }
158
159
  }
@@ -162,5 +163,12 @@ ${Object.values(AllConfigPaths)
162
163
  this.log();
163
164
  }
164
165
  this.logDashboardUrl(flags);
166
+ this.exitIfFilesFailed();
167
+ }
168
+ // A skipped file is a failed push, for scripts and CI too
169
+ exitIfFilesFailed() {
170
+ if (this.failedFiles.length > 0) {
171
+ this.error(`${this.failedFiles.length} file(s) failed to push: ${this.failedFiles.join(', ')}`);
172
+ }
165
173
  }
166
174
  }
@@ -45,6 +45,16 @@ export declare abstract class AppConfig {
45
45
  version?: string;
46
46
  constructor(attrs?: Partial<AppConfig>);
47
47
  load(): Promise<void>;
48
+ /**
49
+ * The hash push compares with the pushed config to skip unchanged files
50
+ * @returns {Promise<string | undefined>} the hash
51
+ */
52
+ protected contentHash(): Promise<string | undefined>;
53
+ /**
54
+ * Full path of the app directory this config belongs to
55
+ * @returns {string} the path
56
+ */
57
+ protected appRoot(): string;
48
58
  static create(attrs?: Partial<AppConfig>): Promise<AppConfig>;
49
59
  isRootConfig(configDir: string): boolean;
50
60
  postData(): Promise<any>;
@@ -88,6 +98,7 @@ export declare class AppConfigFunction extends AppConfig {
88
98
  hasValues: boolean;
89
99
  type: ConfigType;
90
100
  isRootFunction(): boolean;
101
+ protected contentHash(): Promise<string | undefined>;
91
102
  preparePostData(postData: any): Promise<any>;
92
103
  }
93
104
  export declare class AppConfigComponent extends AppConfig {
@@ -3,9 +3,9 @@ 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 } from '../bundle.js';
6
+ import { bundleFunction, bundleComponent, bundleWorkflow, getLocalInputs, } from '../bundle.js';
7
7
  import { analyzeFunctionSource, hasKindDiagnostic, workflowStaticConfigError, } from '../function-source-analysis.js';
8
- import { AllConfigPaths, ConfigType, filePathExistsAsync, hashFile, } from './index.js';
8
+ import { AllConfigPaths, ConfigType, filePathExistsAsync, hashFile, hashString, } from './index.js';
9
9
  export class IgnoringFileError extends Error {
10
10
  constructor(message) {
11
11
  super(message);
@@ -83,11 +83,25 @@ export class AppConfig {
83
83
  if (await filePathExistsAsync(this.appPath)) {
84
84
  this.fileData = await fs.readFile(this.appPath);
85
85
  this.fileHash = hashFile(this.appPath, this.fileData, this.filePath);
86
- this.hash = this.hash || this.fileHash;
86
+ this.hash = this.hash || (await this.contentHash());
87
87
  this.byteSize = this.fileData.length;
88
88
  this.mbSize = this.byteSize / 1024 / 1024;
89
89
  }
90
90
  }
91
+ /**
92
+ * The hash push compares with the pushed config to skip unchanged files
93
+ * @returns {Promise<string | undefined>} the hash
94
+ */
95
+ async contentHash() {
96
+ return this.fileHash;
97
+ }
98
+ /**
99
+ * Full path of the app directory this config belongs to
100
+ * @returns {string} the path
101
+ */
102
+ appRoot() {
103
+ return path.resolve(this.appPath, ...this.filePath.split('/').map(() => '..'));
104
+ }
91
105
  static async create(attrs = {}) {
92
106
  let instance;
93
107
  switch (attrs.type) {
@@ -263,6 +277,27 @@ export class AppConfigFunction extends AppConfig {
263
277
  return (this.isRootConfig('functions') &&
264
278
  (this.filePath.endsWith('.js') || this.filePath.endsWith('.ts')));
265
279
  }
280
+ // A function deploys as a bundle, so the hash includes the local files it
281
+ // imports: a change in shared code under functions/lib redeploys it
282
+ 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);
300
+ }
266
301
  async preparePostData(postData) {
267
302
  if (this.isRootFunction()) {
268
303
  try {
@@ -3,7 +3,7 @@ import { execa } from 'execa';
3
3
  import { isIgnoredByIgnoreFiles } from 'globby';
4
4
  import { createHash } from 'node:crypto';
5
5
  import * as fs from 'node:fs/promises';
6
- import { createRequire } from 'node:module';
6
+ import { createRequire, isBuiltin } from 'node:module';
7
7
  import * as os from 'node:os';
8
8
  import * as path from 'node:path';
9
9
  const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
@@ -155,13 +155,25 @@ export async function checkModuleGraph(pkg) {
155
155
  name: 'retained-frontend-modules',
156
156
  setup(builder) {
157
157
  builder.onResolve({ filter: /.*/ }, (args) => {
158
- if (/^(?:node|cloudflare):/.test(args.path))
158
+ // The provider validates runtime module names at upload, so a
159
+ // scheme is never checked against the Node version running here.
160
+ if (args.kind !== 'entry-point' &&
161
+ (/^(?:node|cloudflare):/.test(args.path) || isBuiltin(args.path)))
159
162
  return { path: args.path, external: true };
163
+ // Like the runtime, resolve any other name, bare or relative,
164
+ // against the importer's directory.
160
165
  const name = args.kind === 'entry-point'
161
166
  ? args.path
162
167
  : path.posix.join(path.posix.dirname(args.importer), args.path);
163
- if (!modules.has(name))
164
- throw new Error(`Missing frontend Worker module: ${name}`);
168
+ if (!modules.has(name)) {
169
+ const from = args.importer
170
+ ? ` (imported as "${args.path}" from "${args.importer}")`
171
+ : '';
172
+ const hint = args.importer && /^[\w@]/.test(args.path)
173
+ ? '. npm dependencies must be bundled.'
174
+ : '';
175
+ throw new Error(`Missing frontend Worker module: ${name}${from}${hint}`);
176
+ }
165
177
  return { path: name, namespace: 'frontend' };
166
178
  });
167
179
  builder.onLoad({ filter: /.*/, namespace: 'frontend' }, (args) => {
@@ -1,4 +1,12 @@
1
1
  import type { FunctionSourceAnalysis } from './function-source-analysis.js';
2
+ /**
3
+ * Local source files an entry point imports, itself included, relative to
4
+ * rootDir. Packages are not followed: only the app's own files are listed.
5
+ * @param {string} entryPoint path of the entry point, relative to rootDir
6
+ * @param {string} rootDir directory the paths are relative to
7
+ * @returns {Promise<string[]>} paths of the imported files
8
+ */
9
+ export declare function getLocalInputs(entryPoint: string, rootDir: string): Promise<string[]>;
2
10
  export declare function bundleFunction(filePath: string): Promise<any>;
3
11
  export declare function bundleWorkflow(filePath: string, analysis: FunctionSourceAnalysis): Promise<any>;
4
12
  export declare function bundleComponent(filePath: string): Promise<any>;
@@ -2,6 +2,27 @@ import * as esbuild from 'esbuild';
2
2
  import * as fs from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ /**
6
+ * Local source files an entry point imports, itself included, relative to
7
+ * rootDir. Packages are not followed: only the app's own files are listed.
8
+ * @param {string} entryPoint path of the entry point, relative to rootDir
9
+ * @param {string} rootDir directory the paths are relative to
10
+ * @returns {Promise<string[]>} paths of the imported files
11
+ */
12
+ export async function getLocalInputs(entryPoint, rootDir) {
13
+ const { metafile } = await esbuild.build({
14
+ absWorkingDir: rootDir,
15
+ bundle: true,
16
+ entryPoints: [entryPoint],
17
+ external: ['cloudflare:*'],
18
+ logLevel: 'silent',
19
+ metafile: true,
20
+ packages: 'external',
21
+ platform: 'node',
22
+ write: false,
23
+ });
24
+ return Object.keys(metafile.inputs);
25
+ }
5
26
  export async function bundleFunction(filePath) {
6
27
  try {
7
28
  const buildResult = await esbuild.build({
@@ -46,6 +46,7 @@ export function createMockRequest(options: MockRequestOptions = {}): SwellReques
46
46
  } = options;
47
47
 
48
48
  const requestHeaders = new Headers(headers);
49
+ const requestQuery = { ...Object.fromEntries(new URL(url).searchParams), ...query };
49
50
  const originalRequest = new Request(url, {
50
51
  method,
51
52
  headers: requestHeaders,
@@ -88,40 +89,20 @@ export function createMockRequest(options: MockRequestOptions = {}): SwellReques
88
89
  apiHost: env.SWELL_API_BASE_URL || "",
89
90
  logParams: undefined,
90
91
  swell: swellClient as SwellAPI,
91
- body: data,
92
- data,
93
- query,
92
+ rawBody: method === "GET" ? "" : JSON.stringify(data),
93
+ body: method === "GET" ? "" : { ...data },
94
+ data: { ...(method === "GET" ? {} : data), ...requestQuery },
95
+ query: requestQuery,
94
96
  initialize: async () => {},
95
- parseJson: (input: string) => JSON.parse(input),
97
+ parseJson: (input: string) => {
98
+ try { return JSON.parse(input); } catch { return {}; }
99
+ },
96
100
  reject: (
97
101
  code: string,
98
102
  message: string,
99
103
  options: { status?: number } = {}
100
104
  ) => {
101
- const status =
102
- typeof options.status === "number" &&
103
- options.status >= 400 &&
104
- options.status < 500
105
- ? options.status
106
- : 422;
107
- const error = new Error(
108
- message || "Request rejected by function"
109
- ) as Error & {
110
- body: { $reject: { code: string; message: string; status: number } };
111
- code: string;
112
- status: number;
113
- };
114
- error.name = "SwellRejection";
115
- error.code = code;
116
- error.status = status;
117
- error.body = {
118
- $reject: {
119
- code,
120
- message: error.message,
121
- status,
122
- },
123
- };
124
- return error;
105
+ return new SwellRejection(code, message, options);
125
106
  },
126
107
  appValues: (idOrValues: string | SwellData, values?: SwellData) => {
127
108
  const targetAppId =
@@ -1,21 +1,6 @@
1
1
  export function setupGlobalsTemplate() {
2
2
  return `\
3
- class SwellErrorImpl extends Error {
4
- status: number;
5
- body?: unknown;
6
-
7
- constructor(message: string | object, options: { status?: number } = {}) {
8
- const text =
9
- typeof message === "string"
10
- ? message
11
- : JSON.stringify(message, null, 2);
12
-
13
- super(text);
14
- this.name = "SwellError";
15
- this.status = options.status ?? 500;
16
- this.body = typeof message === "string" ? undefined : message;
17
- }
18
- }
3
+ import { SwellError } from "@swell/apps-sdk";
19
4
 
20
5
  class SwellRejectionImpl extends Error {
21
6
  status: number;
@@ -51,7 +36,7 @@ class SwellRejectionImpl extends Error {
51
36
  }
52
37
 
53
38
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
- (globalThis as any).SwellError = SwellErrorImpl;
39
+ (globalThis as any).SwellError = SwellError;
55
40
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
41
  (globalThis as any).SwellRejection = SwellRejectionImpl;
57
42
 
@@ -14,7 +14,7 @@ describe("Unit test examples", () => {
14
14
  });
15
15
 
16
16
  expect(req.data.product_id).toBe("prod_123");
17
- expect(req.session.account_id).toBe("acc_456");
17
+ expect(req.session?.account_id).toBe("acc_456");
18
18
  });
19
19
 
20
20
  it("mocks swell.get", async () => {
@@ -7,6 +7,7 @@ const TEST_DEV_DEPENDENCIES = {
7
7
  '@cloudflare/vitest-pool-workers': '0.12.x',
8
8
  vitest: '3.2.x',
9
9
  '@swell/app-types': 'latest',
10
+ '@swell/apps-sdk': '2.0.0-alpha.0',
10
11
  };
11
12
  async function writeTextFile(filePath, contents, overwrite) {
12
13
  if (!overwrite && (await filePathExistsAsync(filePath))) {
@@ -52,6 +53,9 @@ async function updatePackageJson(appPath) {
52
53
  if (!devDeps['@swell/app-types'] && !deps['@swell/app-types']) {
53
54
  devDeps['@swell/app-types'] = TEST_DEV_DEPENDENCIES['@swell/app-types'];
54
55
  }
56
+ if (!devDeps['@swell/apps-sdk'] && !deps['@swell/apps-sdk']) {
57
+ devDeps['@swell/apps-sdk'] = TEST_DEV_DEPENDENCIES['@swell/apps-sdk'];
58
+ }
55
59
  pkg.devDependencies = devDeps;
56
60
  const scripts = (pkg.scripts || {});
57
61
  scripts.test = scripts.test || 'vitest run';
@@ -48,6 +48,13 @@ export declare abstract class PushAppCommand extends RemoteAppCommand {
48
48
  }, shouldChoose?: boolean): Promise<App | undefined>;
49
49
  getExistingApp(appId: string): Promise<App>;
50
50
  getFrontendDeploymentHash(): Promise<string | undefined>;
51
+ /**
52
+ * Files outside frontend/ that the worker's entry point imports, relative to
53
+ * the app path. Only a local entry point set by `main` in the wrangler
54
+ * config is followed; frameworks that build their own entry are not.
55
+ * @returns {Promise<string[]>} paths of the imported files
56
+ */
57
+ getFrontendSharedInputs(): Promise<string[]>;
51
58
  getFrontendProjectType(required?: boolean): Promise<FrontendProjectType | null>;
52
59
  getStorefrontToPull(args?: {
53
60
  appId?: string;
@@ -8,9 +8,10 @@ import ora from 'ora';
8
8
  import { default as swellConfig } from './lib/app-config.js';
9
9
  import { buildFrontendPackage, getManagedFrontendFramework, } from './lib/apps/frontend-package.js';
10
10
  import { warnFrontendRouteCollisions } from './lib/apps/frontend-routes.js';
11
- import { ConfigType, getFrontendProjectValidValues, allConfigFilesInDir, CUSTOM_FRAMEWORK_SLUG, appConfigFromFile, filePathExistsAsync, findAppConfig, getConfigTypeFromPath, getConfigTypeKeyFromValue, getFrontendProjectType, getProjectCommands, globAllFilesByPath, hashString, isPathDirectory, toPosixPath, } from './lib/apps/index.js';
11
+ import { ConfigType, getFrontendProjectValidValues, allConfigFilesInDir, CUSTOM_FRAMEWORK_SLUG, appConfigFromFile, filePathExistsAsync, findAppConfig, getConfigTypeFromPath, getConfigTypeKeyFromValue, getFrontendProjectType, getProjectCommands, globAllFilesByPath, hashFile, hashString, isPathDirectory, toPosixPath, } from './lib/apps/index.js';
12
12
  import { getGlobIgnorePathsChecker } from './lib/apps/paths.js';
13
13
  import { slugFromApp } from './lib/apps/slug.js';
14
+ import { getLocalInputs } from './lib/bundle.js';
14
15
  import { default as localConfig } from './lib/config.js';
15
16
  import { toAppId } from './lib/create/index.js';
16
17
  import { detectPackageManager, transformCommand, } from './lib/package-manager.js';
@@ -21,6 +22,27 @@ import { RemoteAppCommand } from './remote-app-command.js';
21
22
  const PUSH_CONCURRENCY = 3;
22
23
  const WATCH_WINDOW_MS = 100;
23
24
  const DEV_SERVER_FALLBACK_PORT = 3000;
25
+ const WRANGLER_CONFIG_FILES = [
26
+ 'wrangler.jsonc',
27
+ 'wrangler.json',
28
+ 'wrangler.toml',
29
+ ];
30
+ /**
31
+ * The `main` entry point of a wrangler config (JSON, JSONC or TOML)
32
+ * @param {string} frontendPath directory of the wrangler config
33
+ * @returns {string | undefined} the entry point, relative to frontendPath
34
+ */
35
+ function readWranglerMain(frontendPath) {
36
+ for (const name of WRANGLER_CONFIG_FILES) {
37
+ const configPath = path.join(frontendPath, name);
38
+ if (fs.existsSync(configPath)) {
39
+ // "main": "src/index.ts" in JSON, main = "src/index.ts" in TOML
40
+ return fs
41
+ .readFileSync(configPath, 'utf8')
42
+ .match(/^\s*"?main"?\s*[:=]\s*"([^"]+)"/m)?.[1];
43
+ }
44
+ }
45
+ }
24
46
  export class PushAppCommand extends RemoteAppCommand {
25
47
  frontendPath = '';
26
48
  hasLoggedLocalPreviewUrl = false;
@@ -466,9 +488,34 @@ export class PushAppCommand extends RemoteAppCommand {
466
488
  if (frontendHashes.length > 0) {
467
489
  const packageHash = localConfigs.find((config) => config.filePath === 'package.json')?.hash;
468
490
  frontendHashes += `|${packageHash}`;
491
+ // Shared code the worker imports from outside frontend/, e.g. functions/lib
492
+ for (const input of await this.getFrontendSharedInputs()) {
493
+ frontendHashes += `|${hashFile(path.join(this.appPath, input), undefined, input)}`;
494
+ }
469
495
  }
470
496
  return frontendHashes.length > 0 ? hashString(frontendHashes) : undefined;
471
497
  }
498
+ /**
499
+ * Files outside frontend/ that the worker's entry point imports, relative to
500
+ * the app path. Only a local entry point set by `main` in the wrangler
501
+ * config is followed; frameworks that build their own entry are not.
502
+ * @returns {Promise<string[]>} paths of the imported files
503
+ */
504
+ async getFrontendSharedInputs() {
505
+ const main = readWranglerMain(path.join(this.appPath, 'frontend'));
506
+ const entry = main && toPosixPath(path.join('frontend', main));
507
+ if (!entry || !fs.existsSync(path.join(this.appPath, entry))) {
508
+ return [];
509
+ }
510
+ try {
511
+ const inputs = await getLocalInputs(entry, this.appPath);
512
+ return inputs.filter((input) => !input.startsWith('frontend/')).sort();
513
+ }
514
+ catch {
515
+ // Build errors surface when wrangler deploys the worker
516
+ return [];
517
+ }
518
+ }
472
519
  async getFrontendProjectType(required = true) {
473
520
  this.frontendPath = path.join(this.appPath, 'frontend');
474
521
  const frontendExists = await filePathExistsAsync(this.frontendPath);
@@ -12,6 +12,7 @@ export declare abstract class RemoteAppCommand extends AppCommand {
12
12
  app: App;
13
13
  appCreated: boolean;
14
14
  appType: string;
15
+ failedFiles: string[];
15
16
  protected pendingConflicts?: AppConflict[];
16
17
  storefront?: any;
17
18
  storefrontLocalUrl?: string;
@@ -5,7 +5,7 @@ import * as path from 'node:path';
5
5
  import { default as ora } from 'ora';
6
6
  import { AppCommand } from './app-command.js';
7
7
  import { readRcFile, writeRcFile } from './lib/app-config.js';
8
- import { AppConfig, ConfigType, FunctionProcessingError, IgnoringFileError, SwellJsonFields, allBaseFilesInDir, allConfigFilesPaths, appConfigFromFile, batchAppFilesByType, deleteFile, hashFile, writeFile, writeJsonFile, PUSH_CONCURRENCY, } from './lib/apps/index.js';
8
+ import { AppConfig, ConfigType, FunctionProcessingError, IgnoringFileError, SwellJsonFields, allBaseFilesInDir, allConfigFilesPaths, appConfigFromFile, batchAppFilesByType, deleteFile, writeFile, writeJsonFile, PUSH_CONCURRENCY, } from './lib/apps/index.js';
9
9
  import { default as localConfig } from './lib/config.js';
10
10
  import { getAppFrontendHost, getLocalFrontendHost, getLoginHost, getStorefrontFrontendHost, } from './lib/constants.js';
11
11
  import { toAppId } from './lib/create/index.js';
@@ -24,6 +24,8 @@ export class RemoteAppCommand extends AppCommand {
24
24
  // Indicates the app was created during the command execution
25
25
  appCreated = false;
26
26
  appType = 'any';
27
+ // Files skipped because they could not be read or compiled
28
+ failedFiles = [];
27
29
  // Conflicts the user chose to disable, held until the command commits
28
30
  pendingConflicts;
29
31
  // API instance of the target storefront, if applicable
@@ -498,11 +500,11 @@ export class RemoteAppCommand extends AppCommand {
498
500
  return true;
499
501
  try {
500
502
  const localConfig = localBatchConfigs.find((config) => config.filePath === batchConfig.filePath);
501
- if (localConfig) {
502
- const hash = hashFile(batchConfig.appPath, localConfig.fileData, batchConfig.filePath);
503
- if (hash === batchConfig.hash) {
504
- return false;
505
- }
503
+ // Functions pushed by earlier CLI versions carry the plain file hash
504
+ if (localConfig &&
505
+ (localConfig.hash === batchConfig.hash ||
506
+ localConfig.fileHash === batchConfig.hash)) {
507
+ return false;
506
508
  }
507
509
  }
508
510
  catch {
@@ -675,11 +677,13 @@ export class RemoteAppCommand extends AppCommand {
675
677
  if (error instanceof IgnoringFileError) {
676
678
  log &&
677
679
  spinner.fail(`Ignoring file: ${error.message} in ${config.filePath}`);
680
+ this.failedFiles.push(config.filePath);
678
681
  return;
679
682
  }
680
683
  if (error instanceof FunctionProcessingError) {
681
684
  log &&
682
685
  spinner.fail(`${error.message} ${error.cause?.message?.toLowerCase()}`);
686
+ this.failedFiles.push(config.filePath);
683
687
  return;
684
688
  }
685
689
  log && spinner.fail(error.message);
@@ -3594,5 +3594,5 @@
3594
3594
  ]
3595
3595
  }
3596
3596
  },
3597
- "version": "2.9.21"
3597
+ "version": "2.9.22"
3598
3598
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swell/cli",
3
- "version": "2.9.21",
3
+ "version": "2.9.22",
4
4
  "type": "module",
5
5
  "description": "Swell's command line interface/utility",
6
6
  "keywords": [