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
2 changes: 2 additions & 0 deletions documentation/docs/30-add-ons/99-community.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ export default setupGlobal({ TEST_DIR });

Community add-ons are bundled with [tsdown](https://tsdown.dev/) into a single file. Everything is bundled except `sv`. (It is a peer dependency provided at runtime.)

`sv` ships its own copy of [`@sveltejs/sv-utils`](sv-utils), so an add-on that leaves it unbundled will still load. Nothing verifies the version: your add-on runs against whatever `sv` provides, and following its breaking changes is up to you. Bundle it to stay on a version you control.

### `package.json`

Your add-on must have `sv` as a peer dependency. Any `dependencies` declared will **not** be available at runtime, everything must be bundled:
Expand Down
77 changes: 36 additions & 41 deletions packages/sv/src/core/fetch-packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,10 @@ export async function downloadPackage(options: DownloadOptions): Promise<AddonDe
try {
fs.symlinkSync(options.path, dest, 'dir');
} catch (error) {
if (!isNodeError(error)) throw error;
const code = errorCode(error);
// On Windows, symlinks may fail with EPERM if admin privileges aren't available
// In that case, fall back to copying the directory
if (platform() === 'win32' && (error.code === 'EPERM' || error.code === 'EACCES')) {
if (platform() === 'win32' && (code === 'EPERM' || code === 'EACCES')) {
copyDirectorySync(options.path, dest);
} else {
throw error;
Expand Down Expand Up @@ -126,62 +126,57 @@ export async function downloadPackage(options: DownloadOptions): Promise<AddonDe
return await importAddonCode(pkg.name, pkg.version, pkg.exports);
}

async function importAddonCode(
export async function importAddonCode(
pkgName: string,
pkgVersion: string,
exports?: Record<string, string | undefined>
exports?: PackageExports
): Promise<AddonDefinition> {
const issues: string[] = [];
let unresolvedModule = false;

const error = () => {
return new Error(
`Failed to load add-on '${pkgName}@${pkgVersion}':\n- ${issues.join('\n- ')}\n\n` +
`Please report this to the add-on author.`
);
};

if (!exports) {
issues.push(`'exports' field not found in package.json`);
throw error();
}

const svImport = exports['./sv'] ? `${pkgName}/sv` : undefined;
const defaultImport = exports['.'] ? pkgName : undefined;
if (!svImport && !defaultImport) {
issues.push(`export conditions './sv' or '.' are not present in package.json`);
throw error();
}

let details: AddonDefinition | undefined;
// only probe `/sv` when the package actually maps it, otherwise the probe itself
// fails and reports a missing module that the author never declared
const candidates = hasSvExport(exports) ? [`${pkgName}/sv`, pkgName] : [pkgName];

for (const importPath of [svImport, defaultImport]) {
if (!importPath) continue;
for (const specifier of candidates) {
try {
details ??= await import(importPath).then((m) => m.default);
const details: AddonDefinition | undefined = (await import(specifier)).default;
if (details) return details;

issues.push(`'${specifier}' resolved but has no default export`);
} catch (e) {
if (isNodeError(e)) {
if (e.code === 'ERR_MODULE_NOT_FOUND') {
issues.push('the add-on contains dependencies that are not bundled');
throw error();
}
issues.push(`Failed to import add-on '${importPath}': ${e.message}`);
} else {
issues.push(`An unknown error has occurred: ${e}`);
const code = errorCode(e);
// ESM entry points report `ERR_MODULE_NOT_FOUND`, CJS ones report `MODULE_NOT_FOUND`
if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') {
unresolvedModule = true;
}
issues.push(`'${specifier}' failed to load: ${e instanceof Error ? e.message : e}`);
}
}

if (!details) {
throw error();
}
const hint = unresolvedModule
? `\nThis usually means the add-on has dependencies that are not bundled.\n`
: '';

return details;
throw new Error(
`Failed to load add-on '${pkgName}@${pkgVersion}':\n- ${issues.join('\n- ')}\n${hint}\n` +
`Please report this to the add-on author.`
);
}

function isNodeError(err: unknown): err is Error & NodeJS.ErrnoException {
return err instanceof Error;
/** `exports` is only consulted to pick entry points, never to reject a package. */
export function hasSvExport(exports?: PackageExports): boolean {
if (typeof exports !== 'object' || exports === null || Array.isArray(exports)) return false;
return Boolean(exports['./sv']);
}

function errorCode(err: unknown): string | undefined {
return err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined;
}

/** Values are nested condition objects, and the field itself may be a string or an array. */
type PackageExports = string | string[] | Record<string, unknown>;

type PackageJSON = {
name: string;
version: string;
Expand Down
156 changes: 156 additions & 0 deletions packages/sv/src/core/tests/fetch-packages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterAll, describe, expect, it } from 'vitest';
import { hasSvExport, importAddonCode } from '../fetch-packages.ts';

// add-ons are imported by bare specifier, so fixtures have to live where `sv` resolves from
const NODE_MODULES = fileURLToPath(new URL('../../../node_modules', import.meta.url));
const PREFIX = 'sv-fixture-addon-';

type Fixture = { exports?: unknown; main?: string; files: Record<string, string> };

let counter = 0;
function writeFixture(fixture: Fixture): string {
const name = `${PREFIX}${counter++}`;
const dir = path.join(NODE_MODULES, name);

for (const [file, contents] of Object.entries(fixture.files)) {
const filePath = path.join(dir, file);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, contents);
}

const pkg: Record<string, unknown> = { name, version: '1.0.0', type: 'module' };
if (fixture.exports !== undefined) pkg.exports = fixture.exports;
if (fixture.main !== undefined) pkg.main = fixture.main;
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg));

return name;
}

afterAll(() => {
for (const entry of fs.readdirSync(NODE_MODULES)) {
if (entry.startsWith(PREFIX)) {
fs.rmSync(path.join(NODE_MODULES, entry), { recursive: true, force: true });
}
}
});

const ADDON = `export default { id: 'fixture', shortDescription: 'x', homepage: '', options: {}, run: () => {} }`;

describe('hasSvExport', () => {
it('detects a mapped ./sv entry', () => {
expect(hasSvExport({ '.': './a.mjs', './sv': './sv.mjs' })).toBe(true);
});
it('ignores packages without a ./sv entry', () => {
expect(hasSvExport({ '.': './a.mjs' })).toBe(false);
});
it('does not throw on the string form', () => {
expect(hasSvExport('./dist/index.mjs')).toBe(false);
});
it('does not throw on the array form', () => {
expect(hasSvExport(['./dist/index.mjs'])).toBe(false);
});
it('does not throw when exports is absent', () => {
expect(hasSvExport(undefined)).toBe(false);
});
});

describe('importAddonCode', () => {
it('loads a package whose exports field is a bare string', async () => {
const name = writeFixture({
exports: './dist/index.mjs',
files: { 'dist/index.mjs': ADDON }
});
await expect(importAddonCode(name, '1.0.0', './dist/index.mjs')).resolves.toMatchObject({
id: 'fixture'
});
});

it('loads a package that only declares main', async () => {
const name = writeFixture({ main: './dist/index.mjs', files: { 'dist/index.mjs': ADDON } });
await expect(importAddonCode(name, '1.0.0', undefined)).resolves.toMatchObject({
id: 'fixture'
});
});

it('loads a package whose exports only declares conditions', async () => {
const exports = { import: './dist/index.mjs', default: './dist/index.mjs' };
const name = writeFixture({ exports, files: { 'dist/index.mjs': ADDON } });
await expect(importAddonCode(name, '1.0.0', exports)).resolves.toMatchObject({
id: 'fixture'
});
});

it('prefers ./sv over the default entry', async () => {
const exports = { '.': './dist/main.mjs', './sv': './dist/sv.mjs' };
const name = writeFixture({
exports,
files: {
'dist/main.mjs': `export default { id: 'main' }`,
'dist/sv.mjs': `export default { id: 'sv' }`
}
});
await expect(importAddonCode(name, '1.0.0', exports)).resolves.toMatchObject({ id: 'sv' });
});

it('falls back to the default entry when ./sv is missing from the tarball', async () => {
const exports = { '.': './dist/main.mjs', './sv': './dist/gone.mjs' };
const name = writeFixture({ exports, files: { 'dist/main.mjs': ADDON } });
await expect(importAddonCode(name, '1.0.0', exports)).resolves.toMatchObject({
id: 'fixture'
});
});

it('reports unbundled dependencies for an ESM entry', async () => {
const exports = { '.': './dist/index.mjs' };
const name = writeFixture({
exports,
files: { 'dist/index.mjs': `import 'sv-fixture-absent-dep';\n${ADDON}` }
});
await expect(importAddonCode(name, '1.0.0', exports)).rejects.toThrow(
/dependencies that are not bundled/
);
});

it('reports unbundled dependencies for a CJS entry', async () => {
const exports = { '.': './dist/index.cjs' };
const name = writeFixture({
exports,
files: { 'dist/index.cjs': `require('sv-fixture-absent-dep');\nmodule.exports = {}` }
});
await expect(importAddonCode(name, '1.0.0', exports)).rejects.toThrow(
/dependencies that are not bundled/
);
});

it('names the missing module rather than swallowing it', async () => {
const exports = { '.': './dist/index.mjs' };
const name = writeFixture({
exports,
files: { 'dist/index.mjs': `import 'sv-fixture-absent-dep';\n${ADDON}` }
});
await expect(importAddonCode(name, '1.0.0', exports)).rejects.toThrow(/sv-fixture-absent-dep/);
});

it('says so when the entry has no default export', async () => {
const exports = { '.': './dist/index.mjs' };
const name = writeFixture({
exports,
files: { 'dist/index.mjs': `export const addon = {}` }
});
await expect(importAddonCode(name, '1.0.0', exports)).rejects.toThrow(/no default export/);
});

it('never renders an empty bullet', async () => {
const exports = { '.': './dist/index.mjs' };
const name = writeFixture({
exports,
files: { 'dist/index.mjs': `export const addon = {}` }
});
await expect(importAddonCode(name, '1.0.0', exports)).rejects.toThrow(
expect.objectContaining({ message: expect.not.stringMatching(/-\s*\n/) })
);
});
});