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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Support multiple entry points for a single self-hosted dependency via the new `entries` map on `DependencyConfig`. All subpaths of a workspace package (e.g. a barrel `.` and a lazy `./manifest`) are built together in one code-split tsdown build, so modules shared between entries are emitted once as a shared chunk and referenced by every entry — a single runtime instance, eliminating the singleton/identity hazard of bundling each subpath separately ([#74](https://github.com/studiometa/playground/pull/74))

## v0.3.11 - 2026.08.01

### Fixed
Expand Down
8 changes: 8 additions & 0 deletions packages/demo/lib/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// A second entry point that lazily loads the shared `greeter` module. Together
// with the barrel (`index.ts`), which imports the same module statically, this
// exercises the multi-entry code-splitting path: `greeter` must be emitted once
// as a shared chunk referenced by both `index.js` and `manifest.js`.
export async function greetLazily(name: string): Promise<string> {
const { greet } = await import('./greeter.js');
return greet(name, { greeting: 'Lazy hello' });
}
5 changes: 4 additions & 1 deletion packages/demo/meta.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ export default defineWebpackConfig({
{
specifier: 'demo-lib',
source: './lib/**/*.ts',
entry: './lib/index.ts',
entries: {
'.': './lib/index.ts',
'./manifest': './lib/manifest.ts',
},
},
],
loaders: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, posix } from 'node:path';
import { describe, it, expect } from 'vitest';
import { PlaygroundDependenciesPlugin } from './PlaygroundDependenciesPlugin.js';
import type { ResolvedDependency } from '../utils/resolve-dependencies.js';
Expand Down Expand Up @@ -129,6 +132,43 @@ describe('PlaygroundDependenciesPlugin', () => {
expect(importMap.deepmerge).toBe('https://esm.sh/deepmerge');
});

it('prefixes every subpath of a multi-entry dependency', () => {
const deps: ResolvedDependency[] = [
{
specifier: '@studiometa/ui',
importMapValue: '/static/deps/@studiometa/ui/index.js',
type: 'bundle',
entries: [
{
subpath: '.',
specifier: '@studiometa/ui',
name: 'index',
source: '../ui/index.ts',
importMapValue: '/static/deps/@studiometa/ui/index.js',
},
{
subpath: './manifest',
specifier: '@studiometa/ui/manifest',
name: 'manifest',
source: '../ui/manifest.ts',
importMapValue: '/static/deps/@studiometa/ui/manifest.js',
},
],
},
];
const importMap = {
'@studiometa/ui': '/static/deps/@studiometa/ui/index.js',
'@studiometa/ui/manifest': '/static/deps/@studiometa/ui/manifest.js',
};

applyAndGetImportMap(deps, importMap, '/play');

expect(importMap['@studiometa/ui']).toBe('/play/static/deps/@studiometa/ui/index.js');
expect(importMap['@studiometa/ui/manifest']).toBe(
'/play/static/deps/@studiometa/ui/manifest.js',
);
});

it('infers publicPath from webpack output.publicPath', () => {
const deps: ResolvedDependency[] = [
{
Expand Down Expand Up @@ -437,4 +477,118 @@ describe('PlaygroundDependenciesPlugin', () => {
expect(emitted.has(headersDtsPath.replace(/^\//, ''))).toBe(true);
});
});

describe('multi-entry singleton (real tsdown build)', () => {
/**
* Build a fake compilation that records every emitted asset into a
* `path -> code` map, throwing on a duplicate path (mirrors webpack's
* seal-time conflict guard).
*/
function makeFakeCompilation(emitted: Map<string, string>) {
return {
compiler: {
webpack: {
sources: {
RawSource: class {
value: string;
constructor(value: string) {
this.value = value;
}
source() {
return this.value;
}
},
},
},
},
emitAsset(assetPath: string, source: { value: string }) {
if (emitted.has(assetPath)) {
throw new Error(`Conflict: multiple assets emit to the same filename ${assetPath}`);
}
emitted.set(assetPath, source.value);
},
};
}

it('emits shared modules ONCE and references the same chunk from every entry', async () => {
// A workspace package with a barrel (static import) and a manifest
// (dynamic import) that both use the same shared module — the exact
// singleton hazard multi-entry builds are meant to eliminate.
const dir = mkdtempSync(join(tmpdir(), 'playground-multi-entry-'));
try {
mkdirSync(join(dir, 'src'), { recursive: true });
writeFileSync(
join(dir, 'src/shared.ts'),
// A distinctive marker proves where the class body actually lives.
'export class Shared {\n greet() {\n return "SINGLETON_MARKER";\n }\n}\n',
);
writeFileSync(
join(dir, 'src/index.ts'),
"import { Shared } from './shared.js';\nexport { Shared };\nexport const fromBarrel = new Shared();\n",
);
writeFileSync(
join(dir, 'src/manifest.ts'),
"export async function load() {\n const { Shared } = await import('./shared.js');\n return new Shared();\n}\n",
);

const dep: ResolvedDependency = {
specifier: '@test/pkg',
importMapValue: '/static/deps/@test/pkg/index.js',
type: 'bundle',
entries: [
{
subpath: '.',
specifier: '@test/pkg',
name: 'index',
source: './src/index.ts',
importMapValue: '/static/deps/@test/pkg/index.js',
},
{
subpath: './manifest',
specifier: '@test/pkg/manifest',
name: 'manifest',
source: './src/manifest.ts',
importMapValue: '/static/deps/@test/pkg/manifest.js',
},
],
};

const p = new PlaygroundDependenciesPlugin([dep], dir);
const emitted = new Map<string, string>();
await (p as any).processMultiEntryBundle(makeFakeCompilation(emitted), dep);

const base = 'static/deps/@test/pkg';
const indexPath = posix.join(base, 'index.js');
const manifestPath = posix.join(base, 'manifest.js');

// Both named entries are emitted at their subpath filenames.
expect(emitted.has(indexPath)).toBe(true);
expect(emitted.has(manifestPath)).toBe(true);

// Exactly ONE shared JS chunk (not a per-entry duplicate).
const sharedJsChunks = [...emitted.keys()].filter(
(path) =>
path.startsWith(base) &&
path.endsWith('.js') &&
path !== indexPath &&
path !== manifestPath,
);
expect(sharedJsChunks).toHaveLength(1);
const sharedChunkPath = sharedJsChunks[0];
const sharedChunkName = sharedChunkPath.slice(base.length + 1);

// The class body lives in the shared chunk, NOT inlined in either entry.
expect(emitted.get(sharedChunkPath)).toContain('SINGLETON_MARKER');
expect(emitted.get(indexPath)).not.toContain('SINGLETON_MARKER');
expect(emitted.get(manifestPath)).not.toContain('SINGLETON_MARKER');

// Both entries import that same shared chunk as a relative sibling, so
// every consumer resolves the one-and-only module instance.
expect(emitted.get(indexPath)).toContain(`./${sharedChunkName}`);
expect(emitted.get(manifestPath)).toContain(`./${sharedChunkName}`);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 60_000);
});
});
Loading
Loading