Skip to content
Merged
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
9 changes: 6 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,9 +417,12 @@ Each entry:
the app owns authentication below it (see the section above). Must start
with `/`; invalid prefixes fail startup.
- `config` — passed to the plugin verbatim as `api.config`.
- `id` — optional stable identifier (defaults to a name derived from
`module`); names the plugin's private data dir, so set it explicitly if
you load two plugins whose specifiers reduce to the same name.
- `id` — optional stable identifier that names the plugin's private data
dir. Defaults to a name derived from `module`: the file's basename, or —
for a generic basename like `plugin.js`/`index.js` — its parent directory
(`relay/plugin.js` → `relay`), so the conventional `<name>/plugin.js`
layout yields distinct ids with none set. Set it explicitly only if two
specifiers still reduce to the same name.

`activate(api)` receives: `api.fastify` (register routes here),
`api.prefix`, `api.config`, `api.log`, `api.auth.getAgent(request)`
Expand Down
45 changes: 38 additions & 7 deletions src/plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,24 +123,55 @@ export function normalizePrefix(p) {
return trimmed.startsWith('/') && trimmed.length > 1 ? trimmed : '';
}

// Basenames too generic to name a plugin: the near-universal
// '<name>/plugin.js' convention means every such file would derive the same
// id and collide. For these, the id falls back to the parent directory (#596).
const GENERIC_BASENAMES = new Set(['plugin', 'index']);

/**
* The distinguishing name of a plugin FILE: its basename, unless that is
* generic ('plugin.js', 'index.js'), in which case the immediate parent
* directory ('relay/plugin.js' -> 'relay'). The parent directory
* distinguishes '<name>/plugin.js' files from each other WITHOUT pinning the
* machine-specific path prefix (which would move the id — and pluginDir —
* with the deployment). Falls back to the basename when there is no usable
* parent (e.g. './plugin.js' at the cwd root). (#596)
*/
export function fileStem(module) {
const withoutExt = path.basename(module).replace(/\.[cm]?js$/, '');
if (GENERIC_BASENAMES.has(withoutExt.toLowerCase())) {
const parent = path.basename(path.dirname(module));
// The parent directory name is returned AS-IS — a directory legitimately
// named 'foo.js' must stay 'foo.js', not be extension-stripped to 'foo'.
if (parent && parent !== '.' && parent !== '..') return parent;
}
return withoutExt;
}

/**
* Directory-safe plugin id: entry.id, or derived from the module spec.
* Bare package specifiers keep their full path ('@scope/pkg/plugin.js' ->
* 'scope-pkg-plugin') so same-named files in different packages don't
* collide; file paths use the basename, because a machine-specific
* collide; file paths use the basename — or, for a generic basename, the
* parent directory (see fileStem, #596) — because a machine-specific
* directory prefix must not name the plugin's data dir (the id — and with
* it pluginDir — would change whenever the deployment moves). The loader
* additionally rejects duplicate ids, so any residual collision fails the
* boot instead of silently sharing storage.
*/
export function pluginId(spec) {
const module = String(spec.module);
const raw = typeof spec.id === 'string' && spec.id
? spec.id
: (module.startsWith('.') || path.isAbsolute(module)
? path.basename(module)
: module
).replace(/\.[cm]?js$/, '');
// The extension is stripped where it applies — the file basename (via
// fileStem) and a bare specifier's tail — but NOT a parent directory name
// that fileStem may return for a generic basename.
let raw;
if (typeof spec.id === 'string' && spec.id) {
raw = spec.id.replace(/\.[cm]?js$/, '');
} else if (module.startsWith('.') || path.isAbsolute(module)) {
raw = fileStem(module);
} else {
raw = module.replace(/\.[cm]?js$/, '');
}
const id = raw.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
if (!id) throw new Error(`plugins: cannot derive an id from ${JSON.stringify(spec.module)}; set entry.id`);
return id;
Expand Down
47 changes: 47 additions & 0 deletions test/plugins.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,53 @@ describe('plugin loader (#206)', () => {
);
});

it('a generic basename (plugin.js/index.js) derives the parent dir, not "plugin" (#596)', async () => {
// The near-universal '<name>/plugin.js' convention: without this fallback
// every such file collides on the id 'plugin' (and 'index' for index.js).
assert.strictEqual(pluginId({ module: './relay/plugin.js' }), 'relay');
assert.strictEqual(pluginId({ module: '/abs/path/dashboard/plugin.js' }), 'dashboard');
assert.strictEqual(pluginId({ module: './chat/index.js' }), 'chat');
assert.strictEqual(pluginId({ module: './My-App/Plugin.mjs' }), 'my-app');
// A non-generic basename is unchanged (still the basename).
assert.strictEqual(pluginId({ module: './relay/relay.js' }), 'relay');
assert.strictEqual(pluginId({ module: '/some/machine/path/foo.js' }), 'foo');
// No usable parent → falls back to the basename; an explicit id still wins.
assert.strictEqual(pluginId({ module: './plugin.js' }), 'plugin');
assert.strictEqual(pluginId({ module: './relay/plugin.js', id: 'custom' }), 'custom');
// A parent directory whose own name ends in .js keeps it (only the FILE's
// extension is stripped, never the directory name) — so 'foo.js/' and
// 'foo/' don't both collapse to the same 'foo'.
assert.strictEqual(pluginId({ module: './foo.js/plugin.js' }), 'foo-js');
assert.strictEqual(pluginId({ module: './foo/plugin.js' }), 'foo');
});

it('two <name>/plugin.js files load together with no explicit ids (#596)', async () => {
// The exact case that failed before: the CLI '--plugin' form (which can't
// set an id) loading two conventionally-named plugins.
await fs.emptyDir(`${FIXTURE_DIR}/alpha`);
await fs.emptyDir(`${FIXTURE_DIR}/beta`);
await fs.writeFile(`${FIXTURE_DIR}/alpha/plugin.js`,
`export async function activate(api) { api.fastify.get('/alpha/ping', async () => ({ id: 'alpha' })); }`);
await fs.writeFile(`${FIXTURE_DIR}/beta/plugin.js`,
`export async function activate(api) { api.fastify.get('/beta/ping', async () => ({ id: 'beta' })); }`);

await fs.emptyDir(TEST_DATA_DIR);
server = createServer({
logger: false,
forceCloseConnections: true,
root: TEST_DATA_DIR,
plugins: [
{ module: `${FIXTURE_DIR}/alpha/plugin.js`, prefix: '/alpha' },
{ module: `${FIXTURE_DIR}/beta/plugin.js`, prefix: '/beta' },
],
});
// Boots (ids 'alpha' and 'beta', no collision) and both routes answer.
await server.listen({ port: 0, host: '127.0.0.1' });
const url = `http://127.0.0.1:${server.server.address().port}`;
assert.strictEqual((await (await fetch(`${url}/alpha/ping`)).json()).id, 'alpha');
assert.strictEqual((await (await fetch(`${url}/beta/ping`)).json()).id, 'beta');
});

it('an invalid prefix fails listen() loudly', async () => {
// Any provided prefix must validate — including falsy ones, which would
// otherwise mount the app without its WAC exemption.
Expand Down