Skip to content

Commit

Permalink
feat: this.emitFile support prebuilt-chunk type (#4990)
Browse files Browse the repository at this point in the history
* feat: this.emitFile support prebuilt-chunk type

* feat: optimize

* test: add more test

* chore: tweak

* docs: update documentation

* docs: update

* Refine docs

* Minor type refinement

---------

Co-authored-by: Lukas Taegert-Atkinson <lukas.taegert-atkinson@tngtech.com>
Co-authored-by: Lukas Taegert-Atkinson <lukastaegert@users.noreply.github.com>
  • Loading branch information
3 people committed May 22, 2023
1 parent 0ee31f1 commit 06e9045
Show file tree
Hide file tree
Showing 12 changed files with 285 additions and 30 deletions.
54 changes: 49 additions & 5 deletions docs/plugin-development/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1137,9 +1137,9 @@ In general, it is recommended to use `this.addWatchFile` from within the hook th

### this.emitFile

| | |
| ----: | :------------------------------------------------------ |
| Type: | `(emittedFile: EmittedChunk \| EmittedAsset) => string` |
| | |
| --: | :-- |
| Type: | `(emittedFile: EmittedChunk \| EmittedPrebuiltChunk \| EmittedAsset) => string` |

```typescript
interface EmittedChunk {
Expand All @@ -1152,6 +1152,14 @@ interface EmittedChunk {
preserveSignature?: 'strict' | 'allow-extension' | 'exports-only' | false;
}

interface EmittedPrebuiltChunk {
type: 'prebuilt-chunk';
fileName: string;
code: string;
exports?: string[];
map?: SourceMap;
}

interface EmittedAsset {
type: 'asset';
name?: string;
Expand All @@ -1161,9 +1169,9 @@ interface EmittedAsset {
}
```
Emits a new file that is included in the build output and returns a `referenceId` that can be used in various places to reference the emitted file. You can emit either chunks or assets.
Emits a new file that is included in the build output and returns a `referenceId` that can be used in various places to reference the emitted file. You can emit chunks, prebuilt chunks or assets.
In both cases, either a `name` or a `fileName` can be supplied. If a `fileName` is provided, it will be used unmodified as the name of the generated file, throwing an error if this causes a conflict. Otherwise, if a `name` is supplied, this will be used as substitution for `[name]` in the corresponding [`output.chunkFileNames`](../configuration-options/index.md#output-chunkfilenames) or [`output.assetFileNames`](../configuration-options/index.md#output-assetfilenames) pattern, possibly adding a unique number to the end of the file name to avoid conflicts. If neither a `name` nor `fileName` is supplied, a default name will be used.
When emitting chunks or assets, either a `name` or a `fileName` can be supplied. If a `fileName` is provided, it will be used unmodified as the name of the generated file, throwing an error if this causes a conflict. Otherwise, if a `name` is supplied, this will be used as substitution for `[name]` in the corresponding [`output.chunkFileNames`](../configuration-options/index.md#output-chunkfilenames) or [`output.assetFileNames`](../configuration-options/index.md#output-assetfilenames) pattern, possibly adding a unique number to the end of the file name to avoid conflicts. If neither a `name` nor `fileName` is supplied, a default name will be used. Prebuilt chunks must always have a `fileName`.
You can reference the URL of an emitted file in any code returned by a [`load`](#load) or [`transform`](#transform) plugin hook via `import.meta.ROLLUP_FILE_URL_referenceId`. See [File URLs](#file-urls) for more details and an example.
Expand Down Expand Up @@ -1238,6 +1246,42 @@ If there are no dynamic imports, this will create exactly three chunks where the
Note that even though any module id can be used in `implicitlyLoadedAfterOneOf`, Rollup will throw an error if such an id cannot be uniquely associated with a chunk, e.g. because the `id` cannot be reached implicitly or explicitly from the existing static entry points, or because the file is completely tree-shaken. Using only entry points, either defined by the user or of previously emitted chunks, will always work, though.
If the `type` is `prebuilt-chunk`, it emits a chunk with fixed contents provided by the `code` parameter. At the moment, `fileName` is also required to provide the name of the chunk. If it exports some variables, we should list these via the optional `exports`. Via `map` we can provide a sourcemap that corresponds to `code`.
To reference a prebuilt chunk in imports, we need to mark the "module" as external in the [`resolveId`](#resolveid) hook as prebuilt chunks are not part of the module graph. Instead, they behave like assets with chunk meta-data:
```js
function emitPrebuiltChunkPlugin() {
return {
name: 'emit-prebuilt-chunk',
load(id) {
if (id === '/my-prebuilt-chunk.js') {
return {
id,
external: true
};
}
},
buildStart() {
this.emitFile({
type: 'prebuilt-chunk',
fileName: 'my-prebuilt-chunk.js',
code: 'export const foo = "foo"',
exports: ['foo']
});
}
};
}
```
Then you can reference the prebuilt chunk in your code:
```js
import { foo } from '/my-prebuilt-chunk.js';
```
Currently, emitting a prebuilt chunk is a basic feature. Looking forward to your feedback.
If the `type` is _`asset`_, then this emits an arbitrary new file with the given `source` as content. It is possible to defer setting the `source` via [`this.setAssetSource(referenceId, source)`](#this-setassetsource) to a later time to be able to reference a file during the build phase while setting the source separately for each output during the generate phase. Assets with a specified `fileName` will always generate separate files while other emitted assets may be deduplicated with existing assets if they have the same source even if the `name` does not match. If an asset without a `fileName` is not deduplicated, the [`output.assetFileNames`](../configuration-options/index.md#output-assetfilenames) name pattern will be used. If `needsCodeReference` is set to `true` and this asset is not referenced by any code in the output via `import.meta.ROLLUP_FILE_URL_referenceId`, then Rollup will not emit it. This also respects references removed via tree-shaking, i.e. if the corresponding `import.meta.ROLLUP_FILE_URL_referenceId` is part of the source code but is not actually used and the reference is removed by tree-shaking, then the asset is not emitted.
### this.error
Expand Down
10 changes: 9 additions & 1 deletion src/rollup/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,15 @@ export interface EmittedChunk {
type: 'chunk';
}

export type EmittedFile = EmittedAsset | EmittedChunk;
export interface EmittedPrebuiltChunk {
code: string;
exports?: string[];
fileName: string;
map?: SourceMap;
type: 'prebuilt-chunk';
}

export type EmittedFile = EmittedAsset | EmittedChunk | EmittedPrebuiltChunk;

export type EmitFile = (emittedFile: EmittedFile) => string;

Expand Down
116 changes: 95 additions & 21 deletions src/utils/FileEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import type Chunk from '../Chunk';
import type Graph from '../Graph';
import type Module from '../Module';
import type {
EmittedAsset,
EmittedChunk,
EmittedPrebuiltChunk,
NormalizedInputOptions,
NormalizedOutputOptions,
OutputChunk,
WarningHandler
} from '../rollup/types';
import { BuildPhase } from './buildPhase';
Expand Down Expand Up @@ -71,46 +74,47 @@ function reserveFileNameInBundle(
}
}

interface ConsumedChunk {
fileName: string | undefined;
type ConsumedChunk = Pick<EmittedChunk, 'fileName' | 'type'> & {
module: null | Module;
name: string;
referenceId: string;
type: 'chunk';
}
};

type ConsumedPrebuiltChunk = EmittedPrebuiltChunk & {
referenceId: string;
};

interface ConsumedAsset {
fileName: string | undefined;
name: string | undefined;
type ConsumedAsset = EmittedAsset & {
needsCodeReference: boolean;
referenceId: string;
source: string | Uint8Array | undefined;
type: 'asset';
}
};

type ConsumedFile = ConsumedChunk | ConsumedAsset | ConsumedPrebuiltChunk;

type EmittedFileType = ConsumedFile['type'];

interface EmittedFile {
[key: string]: unknown;
fileName?: string;
name?: string;
needsCodeReference?: boolean;
type: 'chunk' | 'asset';
type: EmittedFileType;
}

type ConsumedFile = ConsumedChunk | ConsumedAsset;
const emittedFileTypes: Set<EmittedFileType> = new Set(['chunk', 'asset', 'prebuilt-chunk']);

function hasValidType(
emittedFile: unknown
): emittedFile is { [key: string]: unknown; type: 'asset' | 'chunk' } {
function hasValidType(emittedFile: unknown): emittedFile is {
[key: string]: unknown;
type: EmittedFileType;
} {
return Boolean(
emittedFile &&
((emittedFile as { [key: string]: unknown }).type === 'asset' ||
(emittedFile as { [key: string]: unknown }).type === 'chunk')
emittedFileTypes.has((emittedFile as { [key: string]: unknown; type: EmittedFileType }).type)
);
}

function hasValidName(emittedFile: {
[key: string]: unknown;
type: 'asset' | 'chunk';
type: EmittedFileType;
}): emittedFile is EmittedFile {
const validatedName = emittedFile.fileName || emittedFile.name;
return !validatedName || (typeof validatedName === 'string' && !isPathFragment(validatedName));
Expand Down Expand Up @@ -182,16 +186,19 @@ export class FileEmitter {
if (!hasValidType(emittedFile)) {
return error(
errorFailedValidation(
`Emitted files must be of type "asset" or "chunk", received "${
`Emitted files must be of type "asset", "chunk" or "prebuilt-chunk", received "${
emittedFile && (emittedFile as any).type
}".`
)
);
}
if (emittedFile.type === 'prebuilt-chunk') {
return this.emitPrebuiltChunk(emittedFile);
}
if (!hasValidName(emittedFile)) {
return error(
errorFailedValidation(
`The "fileName" or "name" properties of emitted files must be strings that are neither absolute nor relative paths, received "${
`The "fileName" or "name" properties of emitted chunks and assets must be strings that are neither absolute nor relative paths, received "${
emittedFile.fileName || emittedFile.name
}".`
)
Expand All @@ -216,6 +223,9 @@ export class FileEmitter {
if (emittedFile.type === 'chunk') {
return getChunkFileName(emittedFile, this.facadeChunkByModule);
}
if (emittedFile.type === 'prebuilt-chunk') {
return emittedFile.fileName;
}
return getAssetFileName(emittedFile, fileReferenceId);
};

Expand Down Expand Up @@ -270,6 +280,8 @@ export class FileEmitter {
const sourceHash = getSourceHash(consumedFile.source);
getOrCreate(consumedAssetsByHash, sourceHash, () => []).push(consumedFile);
}
} else if (consumedFile.type === 'prebuilt-chunk') {
this.output.bundle[consumedFile.fileName] = this.createPrebuiltChunk(consumedFile);
}
}
for (const [sourceHash, consumedFiles] of consumedAssetsByHash) {
Expand Down Expand Up @@ -298,6 +310,28 @@ export class FileEmitter {
return referenceId;
}

private createPrebuiltChunk(prebuiltChunk: ConsumedPrebuiltChunk): OutputChunk {
return {
code: prebuiltChunk.code,
dynamicImports: [],
exports: prebuiltChunk.exports || [],
facadeModuleId: null,
fileName: prebuiltChunk.fileName,
implicitlyLoadedBefore: [],
importedBindings: {},
imports: [],
isDynamicEntry: false,
isEntry: false,
isImplicitEntry: false,
map: prebuiltChunk.map || null,
moduleIds: [],
modules: {},
name: prebuiltChunk.fileName,
referencedFiles: [],
type: 'chunk'
};
}

private emitAsset(emittedAsset: EmittedFile): string {
const source =
emittedAsset.source === undefined
Expand Down Expand Up @@ -367,6 +401,46 @@ export class FileEmitter {
return this.assignReferenceId(consumedChunk, emittedChunk.id);
}

private emitPrebuiltChunk(
emitPrebuiltChunk: Omit<EmittedFile, 'fileName' | 'name'> &
Pick<EmittedPrebuiltChunk, 'exports' | 'map'>
): string {
if (typeof emitPrebuiltChunk.code !== 'string') {
return error(
errorFailedValidation(
`Emitted prebuilt chunks need to have a valid string code, received "${emitPrebuiltChunk.code}".`
)
);
}
if (
typeof emitPrebuiltChunk.fileName !== 'string' ||
isPathFragment(emitPrebuiltChunk.fileName)
) {
return error(
errorFailedValidation(
`The "fileName" property of emitted prebuilt chunks must be strings that are neither absolute nor relative paths, received "${emitPrebuiltChunk.fileName}".`
)
);
}
const consumedPrebuiltChunk: ConsumedPrebuiltChunk = {
code: emitPrebuiltChunk.code,
exports: emitPrebuiltChunk.exports,
fileName: emitPrebuiltChunk.fileName,
map: emitPrebuiltChunk.map,
referenceId: '',
type: 'prebuilt-chunk'
};
const referenceId = this.assignReferenceId(
consumedPrebuiltChunk,
consumedPrebuiltChunk.fileName
);
if (this.output) {
this.output.bundle[consumedPrebuiltChunk.fileName] =
this.createPrebuiltChunk(consumedPrebuiltChunk);
}
return referenceId;
}

private finalizeAdditionalAsset(
consumedFile: Readonly<ConsumedAsset>,
source: string | Uint8Array,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module.exports = defineTest({
code: 'PLUGIN_ERROR',
hook: 'buildStart',
message:
'The "fileName" or "name" properties of emitted files must be strings that are neither absolute nor relative paths, received "/test.ext".',
'The "fileName" or "name" properties of emitted chunks and assets must be strings that are neither absolute nor relative paths, received "/test.ext".',
plugin: 'test-plugin',
pluginCode: 'VALIDATION_ERROR'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module.exports = defineTest({
code: 'PLUGIN_ERROR',
hook: 'buildStart',
message:
'The "fileName" or "name" properties of emitted files must be strings that are neither absolute nor relative paths, received "F:\\test.ext".',
'The "fileName" or "name" properties of emitted chunks and assets must be strings that are neither absolute nor relative paths, received "F:\\test.ext".',
plugin: 'test-plugin',
pluginCode: 'VALIDATION_ERROR'
}
Expand Down
3 changes: 2 additions & 1 deletion test/function/samples/emit-file/invalid-file-type/_config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ module.exports = defineTest({
error: {
code: 'PLUGIN_ERROR',
hook: 'buildStart',
message: 'Emitted files must be of type "asset" or "chunk", received "unknown".',
message:
'Emitted files must be of type "asset", "chunk" or "prebuilt-chunk", received "unknown".',
plugin: 'test-plugin',
pluginCode: 'VALIDATION_ERROR'
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module.exports = defineTest({
description: 'throws for invalid prebuilt chunks filename',
options: {
plugins: {
name: 'test-plugin',
buildStart() {
this.emitFile({
type: 'prebuilt-chunk',
code: 'console.log("my-chunk")'
});
}
}
},
error: {
code: 'PLUGIN_ERROR',
hook: 'buildStart',
message:
'The "fileName" property of emitted prebuilt chunks must be strings that are neither absolute nor relative paths, received "undefined".',
plugin: 'test-plugin',
pluginCode: 'VALIDATION_ERROR'
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
throw new Error('should not build');
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
module.exports = defineTest({
description: 'throws for invalid prebuilt chunks code',
options: {
plugins: {
name: 'test-plugin',
buildStart() {
this.emitFile({
type: 'prebuilt-chunk',
fileName: 'my-chunk.js'
});
}
}
},
error: {
code: 'PLUGIN_ERROR',
hook: 'buildStart',
message: 'Emitted prebuilt chunks need to have a valid string code, received "undefined".',
plugin: 'test-plugin',
pluginCode: 'VALIDATION_ERROR'
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
throw new Error('should not build');
Loading

0 comments on commit 06e9045

Please sign in to comment.