Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/esm-output-file-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@strapi/sdk-plugin': patch
---

Name build output files after the `import` / `require` paths in the package.json exports map instead of hardcoding `index.mjs` / `index.js`, so `"type": "module"` plugins can emit `.cjs` CommonJS and `.js` ESM. `./strapi-admin` and `./strapi-server` now need only one of `import` or `require`, which allows ESM-only plugins. The build warns when an output extension makes Node load it in the wrong module format.
108 changes: 108 additions & 0 deletions src/__tests__/e2e/build.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';

import { BUILD_TEST_TIMEOUT_MS, getFixturePath, invokeCLI, withMockedCLI } from './test-utils';

import type { Logger } from '../../cli/commands/utils/logger';

describe('build command', () => {
it('should validate package.json before building', async () => {
await withMockedCLI('typescript-plugin', async ({ command }) => {
Expand Down Expand Up @@ -115,4 +118,109 @@ describe('build command', () => {
expect(fs.existsSync(path.join(distTypesDir, 'index.d.ts'))).toBe(true);
});
});

describe('"type": "module" plugin', () => {
jest.setTimeout(BUILD_TEST_TIMEOUT_MS);

const fixturePath = getFixturePath('esm-type-plugin');
const distDir = path.join(fixturePath, 'dist');
const distServerDir = path.join(distDir, 'server');

afterEach(() => {
fs.rmSync(distDir, { recursive: true, force: true });
});

it('should name output files after the exports map', async () => {
const { build } = await import('../../cli/commands/utils/build');
const { createLogger } = await import('../../cli/commands/utils/logger');
const logger = createLogger({ silent: true, debug: false, timestamp: false });

await build({
cwd: fixturePath,
logger,
silent: true,
});

expect(fs.readdirSync(distServerDir).sort()).toEqual(['index.cjs', 'index.js']);
});

it('should produce output that Node loads through require and import', async () => {
const { build } = await import('../../cli/commands/utils/build');
const { createLogger } = await import('../../cli/commands/utils/logger');
const logger = createLogger({ silent: true, debug: false, timestamp: false });

await build({
cwd: fixturePath,
logger,
silent: true,
});

const script = `
const assert = require('node:assert');
const cjs = require('./dist/server/index.cjs');
assert.strictEqual(typeof (cjs.default ?? cjs).register, 'function');
import('./dist/server/index.js').then((esm) => {
assert.strictEqual(typeof esm.default.register, 'function');
});
`;

// Run in a child process from the fixture so Node applies its "type": "module"
expect(() =>
execFileSync(process.execPath, ['--input-type=commonjs', '-e', script], {
cwd: fixturePath,
stdio: 'pipe',
})
).not.toThrow();
});

it('should warn when require points to a .js file in a "type": "module" package', async () => {
const { createViteConfig } = await import('../../cli/commands/utils/build/vite-config');
const logger = { warn: jest.fn() } as unknown as Logger;

await createViteConfig({
cwd: fixturePath,
logger,
bundle: {
type: 'server',
source: './server/src/index.js',
output: { cjs: './dist/server/index.js' },
},
});

expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Use a ".cjs" extension'));
});
});

describe('ESM-only "type": "module" plugin', () => {
jest.setTimeout(BUILD_TEST_TIMEOUT_MS);

const fixturePath = getFixturePath('esm-only-plugin');
const distDir = path.join(fixturePath, 'dist');

afterEach(() => {
fs.rmSync(distDir, { recursive: true, force: true });
});

it('should build only the ES output, to the import path', async () => {
const { build } = await import('../../cli/commands/utils/build');
const { createLogger } = await import('../../cli/commands/utils/logger');
const logger = createLogger({ silent: true, debug: false, timestamp: false });

await build({
cwd: fixturePath,
logger,
silent: true,
});

expect(fs.readdirSync(path.join(distDir, 'server'))).toEqual(['index.js']);

const script = `import('./dist/server/index.js').then((esm) => {
if (typeof esm.default.register !== 'function') process.exit(1);
});`;

expect(() =>
execFileSync(process.execPath, ['-e', script], { cwd: fixturePath, stdio: 'pipe' })
).not.toThrow();
});
});
});
34 changes: 33 additions & 1 deletion src/__tests__/e2e/verify.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { BUILD_TEST_TIMEOUT_MS, ensureFixtureBuilt, invokeCLI, withMockedCLI } from './test-utils';
import fs from 'node:fs';
import path from 'node:path';

import {
BUILD_TEST_TIMEOUT_MS,
ensureFixtureBuilt,
getFixturePath,
invokeCLI,
withMockedCLI,
} from './test-utils';

describe('verify command', () => {
it(
Expand All @@ -17,6 +26,29 @@ describe('verify command', () => {
BUILD_TEST_TIMEOUT_MS
);

it(
'should verify an ESM-only plugin with no require export',
async () => {
await ensureFixtureBuilt('esm-only-plugin');

try {
await withMockedCLI('esm-only-plugin', async ({ command, mockExit }) => {
const cli = await invokeCLI(['verify', '--silent'], command);

await cli.parseAsync(['node', 'strapi-plugin', 'verify', '--silent']);

expect(mockExit).not.toHaveBeenCalled();
});
} finally {
fs.rmSync(path.join(getFixturePath('esm-only-plugin'), 'dist'), {
recursive: true,
force: true,
});
}
},
BUILD_TEST_TIMEOUT_MS
);

it('should fail for plugin with invalid exports', async () => {
await withMockedCLI('typescript-plugin', async () => {
// This test will be more useful when we have fixtures with invalid exports
Expand Down
35 changes: 35 additions & 0 deletions src/__tests__/fixtures/esm-only-plugin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@strapi/plugin-esm-only-test",
"version": "1.0.0",
"description": "ESM-only test plugin (no require export) for E2E testing",
"type": "module",
"exports": {
"./strapi-server": {
"source": "./server/src/index.js",
"import": "./dist/server/index.js",
"default": "./dist/server/index.js"
},
"./package.json": "./package.json"
},
"files": [
"dist"
],
"scripts": {
"build": "strapi-plugin build",
"watch": "strapi-plugin watch",
"verify": "strapi-plugin verify"
},
"devDependencies": {
"@strapi/strapi": "^5.0.0",
"@strapi/sdk-plugin": "^6.0.0"
},
"peerDependencies": {
"@strapi/strapi": "^5.0.0"
},
"strapi": {
"name": "@strapi/plugin-esm-only-test",
"displayName": "ESM Only Test Plugin",
"description": "ESM-only test plugin (no require export) for E2E testing",
"kind": "plugin"
}
}
8 changes: 8 additions & 0 deletions src/__tests__/fixtures/esm-only-plugin/server/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default {
register() {
// Register phase
},
bootstrap() {
// Bootstrap phase
},
};
36 changes: 36 additions & 0 deletions src/__tests__/fixtures/esm-type-plugin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "@strapi/plugin-esm-type-test",
"version": "1.0.0",
"description": "Test plugin with \"type\": \"module\" for E2E testing",
"type": "module",
"exports": {
"./strapi-server": {
"source": "./server/src/index.js",
"import": "./dist/server/index.js",
"require": "./dist/server/index.cjs",
"default": "./dist/server/index.cjs"
},
"./package.json": "./package.json"
},
"files": [
"dist"
],
"scripts": {
"build": "strapi-plugin build",
"watch": "strapi-plugin watch",
"verify": "strapi-plugin verify"
},
"devDependencies": {
"@strapi/strapi": "^5.0.0",
"@strapi/sdk-plugin": "^6.0.0"
},
"peerDependencies": {
"@strapi/strapi": "^5.0.0"
},
"strapi": {
"name": "@strapi/plugin-esm-type-test",
"displayName": "ESM Type Test Plugin",
"description": "Test plugin with \"type\": \"module\" for E2E testing",
"kind": "plugin"
}
}
8 changes: 8 additions & 0 deletions src/__tests__/fixtures/esm-type-plugin/server/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default {
register() {
// Register phase
},
bootstrap() {
// Bootstrap phase
},
};
1 change: 1 addition & 0 deletions src/cli/commands/utils/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,17 @@
logger.info(`Building ${bundle.type} bundle...`);
}

const config = await createViteConfig({
cwd,
bundle,
minify,
sourcemap,
silent,
logger,
});

Check warning on line 115 in src/cli/commands/utils/build/index.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` inside a loop.

See more on https://sonarcloud.io/project/issues?id=strapi_sdk-plugin&issues=AaD8_ZXqFyFH5QVgl9Ub&open=AaD8_ZXqFyFH5QVgl9Ub&pullRequest=243

const { build: viteBuild } = await import('vite');

Check warning on line 117 in src/cli/commands/utils/build/index.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` inside a loop.

See more on https://sonarcloud.io/project/issues?id=strapi_sdk-plugin&issues=AaD8_ZXqFyFH5QVgl9Uc&open=AaD8_ZXqFyFH5QVgl9Uc&pullRequest=243
await viteBuild(config);

Check warning on line 118 in src/cli/commands/utils/build/index.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` inside a loop.

See more on https://sonarcloud.io/project/issues?id=strapi_sdk-plugin&issues=AaD8_ZXqFyFH5QVgl9Ud&open=AaD8_ZXqFyFH5QVgl9Ud&pullRequest=243

if (!silent) {
logger.info(`${bundle.type} bundle built successfully`);
Expand Down
Loading
Loading