Crear un bundler o un procesador
Las dos maneras de enseñarle a Beyond Packages una nueva forma de compilar un módulo público, mostradas con los bundlers que incluye: un bundler escrito contra el contrato central de módulos, un bundler compuesto de procesadores con el SDK, y un procesador reutilizable con sus entradas, salidas, diagnósticos, dependencias y limpieza.
- Disponibilidad: Experimental
- Evidencia: Ejecución registrada
- Tutorial
De dónde viene un bundler
Un paquete nombra los bundlers que usa y de dónde viene cada uno, y cada módulo selecciona uno, o toma el predeterminado del paquete:
{
"beyond": { "modules": ".", "bundler": "ts" },
"bundlers": { "ts": { "specifier": "@beyond-js/packages/bundlers/ts", "runtime": "@beyond-js/local-2026/bundle" } }
}El especificador es un módulo público que el proceso en ejecución importa de forma nativa; debe exportar una clase llamada Module. Un procesador también es un módulo público, que exporta una clase llamada Processor. Ninguno se registra en otro lugar: quien ejecuta Packages decide de dónde vienen los módulos públicos.
Un bundler contra el contrato central
El contrato central, de @beyond-js/packages/module, son dos clases. BaseModule enumera los condicionales de un módulo (una plataforma, opcionalmente con un entorno) y construye un objeto por condicional; BaseConditional es ese objeto: recibe la especificación del módulo, la procesa hasta una salida e informa sus diagnósticos. No se requiere nada más: el bundler se encarga de sus herramientas, sus salidas y su vigilancia.
El bundler exports compila un condicional de Node a partir de una exportación del paquete con esbuild:
import type { IConditions } from '@beyond-js/packages/types';
import { BaseModule } from '@beyond-js/packages/module';
import { Conditional } from './conditional';
export /*bundle*/ class Module extends BaseModule {
_conditionals(): IConditions[] {
return [{ platform: 'node' }];
}
_conditional({ key }: { key: string }): Conditional {
if (key !== 'node') throw new Error(`Conditional ${key} not implemented`);
return new Conditional(this, { platform: 'node' });
}
}import type { IProcessedSpec } from '@beyond-js/packages/module';
import type { ExportsType, ExportsTargetType } from '@beyond-js/packages/types';
import type { IDiagnostic } from '@beyond-js/packages/types';
import type { BuildResult } from 'esbuild';
import { BaseConditional } from '@beyond-js/packages/module';
import { ConditionalOutput } from '@beyond-js/packages/module/output';
import { build } from 'esbuild';
import { Plugin } from './plugin';
import * as lexer from 'cjs-module-lexer';
import { Wrapper } from './wrapper';
import { sep } from 'path';
export class Conditional extends BaseConditional {
get dp() {
return 'exports-bundler.outputs';
}
#errors: IDiagnostic[] = [];
get errors(): IDiagnostic[] {
return this.#errors;
}
get valid(): boolean {
return !this.#errors.length;
}
#target: string;
_spec(spec: ExportsType): IProcessedSpec {
this.#target = (<any>spec).node.require;
return { values: this.#target };
}
#output: ConditionalOutput = new ConditionalOutput();
get output(): ConditionalOutput {
return this.#output;
}
async _process(): Promise<void | boolean> {
if (!this.#target) {
const code = 'NO_TARGET';
const message = 'No target defined for the conditional';
this.#errors = [{ code, message }];
return;
}
const entry = this.#target;
if (typeof entry !== 'string' || !entry) {
const code = 'INVALID_TARGET';
const message = `Invalid target: ${entry}`;
this.#errors = [{ code, message }];
return;
}
console.log('Building exports with entry:', entry);
let result: BuildResult;
const plugin = new Plugin(this);
try {
result = await build({
entryPoints: [entry],
format: 'cjs',
sourcemap: 'external',
logLevel: 'silent',
platform: 'browser',
bundle: true,
write: false,
outfile: 'out.js',
plugins: [plugin]
});
} catch (exc) {
console.log('Build exception', exc);
const code = 'BUNDLER_EXCEPTION';
const message = `Exception caught: ${exc.message}`;
this.#errors = [{ code, message }];
return;
}
const { warnings, outputFiles: outputs } = result;
if (result.errors?.length) {
const code = 'BUNDLER_ERRORS';
const message = 'Errors found during the bundling process';
this.#errors = [{ code, message }];
return;
}
await lexer.init();
const { code, map }: { code: string; map: string } = (() => {
const output = { code: '', map: '' };
output.code = outputs?.find(({ path }) => path.endsWith(`${sep}out.js`))?.text;
output.map = outputs?.find(({ path }) => path.endsWith(`${sep}out.js.map`))?.text;
const { exports } = lexer.parse(output.code);
const wrap = new Wrapper();
const externals = plugin.externals;
const esm = wrap.build({ code: output.code, map: output.map, externals, exports });
output.code = esm.code;
output.map = esm.map;
return output;
})();
this.#errors = [];
this.#output.set({ code, map });
require('fs').writeFileSync(`${process.cwd()}/output.js`, code);
console.log('Build output code written to output.js');
}
}Su _spec conserva de la especificación lo que necesita (el destino), así que un cambio de cualquier otra cosa no lo reprocesa; su _process ejecuta la herramienta y llena su output, un ConditionalOutput con código y mapa; sus errors son lo que el servicio informa como diagnósticos del módulo.
Un bundler compuesto de procesadores, con el SDK
El SDK, de @beyond-js/packages/sdk, es para un bundler cuyos condicionales se ensamblan a partir de las salidas de varios procesadores. ESMConditional es el ensamblado: reúne los módulos internos, las hojas de estilos y las declaraciones que producen los procesadores, en orden determinista, y genera el artefacto del condicional contra el runtime que selecciona el paquete, con el registro de un widget cuando el manifiesto declara uno.
import type { IModuleManifestInfo } from '@beyond-js/packages/module/spec';
import { BaseModule } from '@beyond-js/packages/sdk';
import { ESM } from './esm';
import { Types } from './types';
/**
* The TypeScript bundler: the runtime-composition mode of a public module.
*
* Every platform the module declares is one ESM conditional, whose artifact registers one creator per
* source file in the runtime the package selects. A `types` conditional is added for the semantic check
* of the sources and the public declaration of the module; it is not an executable artifact, and the
* consumers that deliver code never select it.
*/
export /*bundle*/ class Module extends BaseModule {
static TYPES = 'types';
_conditionals() {
const spec = <IModuleManifestInfo>this.spec.values;
let platforms: string[] = spec.platforms;
platforms = typeof platforms === 'string' ? [platforms] : platforms;
platforms = platforms || ['default'];
platforms = platforms?.filter(platform => platform && typeof platform === 'string' && platform !== Module.TYPES);
platforms = platforms instanceof Array && platforms.length ? platforms : ['default'];
return [...platforms.map(platform => ({ platform })), { platform: Module.TYPES }];
}
_conditional({ conditions }: { key: string; conditions: { platform: string } }) {
const { platform } = conditions;
return platform === Module.TYPES ? new Types(this, { platform }) : new ESM(this, { platform });
}
}import type { IProcessorsSetup } from '@beyond-js/packages/sdk';
import type { IProcessedSpec } from '@beyond-js/packages/module';
import { ESMConditional } from '@beyond-js/packages/sdk';
import { Spec } from './spec';
/**
* The processors of the TypeScript bundler, by name and public module. `ts` transforms the TypeScript and
* TSX sources; `styles` compiles the CSS and SCSS sources, with Tailwind where a stylesheet imports it;
* `vue` and `svelte` compile the single-file components of those frameworks into internal modules and
* stylesheets. A processor whose inputs are absent from a module produces nothing and loads no compiler.
*/
const PROCESSORS = {
ts: '@beyond-js/packages/bundlers/ts/processors/ts',
styles: '@beyond-js/packages/bundlers/ts/processors/styles',
vue: '@beyond-js/packages/bundlers/ts/processors/vue',
svelte: '@beyond-js/packages/bundlers/ts/processors/svelte'
};
/**
* The executable conditional of a module compiled by the TypeScript bundler, for one platform
*/
export /*bundle*/ class ESM extends ESMConditional {
_spec(values: Record<string, any>): IProcessedSpec {
return Spec.values(values, this.platform, this.environment);
}
/**
* Every processor receives the values of the conditional that are not reserved for the module, and
* the style processor also receives the tailwind inputs the manifest declares
*/
_processors(): IProcessorsSetup {
const values = <Record<string, any>>this.spec.values;
const processors = new Map(
Object.entries(PROCESSORS).map(([name, specifier]) => {
const extra = name === 'styles' && values?.tailwind !== void 0 ? { tailwind: values.tailwind } : {};
return [name, Spec.processor(values, specifier, extra)];
})
);
return { processors };
}
}Un bundler así decide tres cosas: qué condicionales tiene un módulo (_conditionals), qué procesadores ejecuta cada condicional y con qué valores (_processors), y cómo se proyectan los valores del manifiesto para un condicional (_spec, que es como conditionals.web y conditionals.node dan a un módulo una entrada por plataforma).
Un procesador reutilizable
Un procesador, de @beyond-js/packages/sdk, extiende ConditionalProcessor. Su constructor declara sus fuentes: las extensiones de los archivos que toma del directorio del módulo (inputs), y archivos auxiliares fijos como un tsconfig.json (files), todos vigilados. Su _build lee las entradas y escribe en outputs: ims para módulos internos, styles para hojas de estilos, types para declaraciones, cada salida con su código, su mapa y sus incidencias.
import type { Conditional, ProcessorOutputs } from '@beyond-js/packages/sdk';
import type { IRequest } from '@beyond-js/dynamic-processor/main';
import type { IDiagnostic } from '@beyond-js/packages/types';
import { ConditionalProcessor } from '@beyond-js/packages/sdk';
import { join } from 'path';
import { Dependencies } from './dependencies';
import { Sass } from './sass';
import { Tailwind, type ITailwindSettings } from './tailwind';
export type { ITailwindSettings } from './tailwind';
/**
* Compiles the stylesheets of a public module: its `.css` and `.scss` sources, each into one style output
* that the conditional concatenates, in file order, into the stylesheet of the module.
*
* A source is compiled with Sass, or with Tailwind when it imports it. Partials (`_name.scss`) produce
* nothing of their own. What a compilation reads besides its source (partials, a theme, the sources
* Tailwind scans, a plugin) is watched as a dependency of the processor, so editing any of it rebuilds
* the stylesheet; a dependency a later build no longer reads is released. Nothing is scanned outside the
* module directory: the Tailwind sources are the ones the module manifest declares.
*/
export /*bundle*/ class Processor extends ConditionalProcessor {
#dependencies: Dependencies;
/**
* The files the last build read besides its inputs
*/
get dependencies(): string[] {
return this.#dependencies.files;
}
#diagnostics: IDiagnostic[] = [];
get errors(): IDiagnostic[] {
return this.#diagnostics.concat(super.errors);
}
constructor(conditional: Conditional, name: string) {
super(conditional, name, { sources: { inputs: { extname: ['.css', '.scss', '.sass'] } } });
this.#dependencies = new Dependencies(this);
}
/**
* Besides the selection of the inputs, the processor keeps the tailwind inputs the manifest declares,
* so changing them reprocesses the module
*/
_spec(values: any) {
const { values: output, errors, warnings } = super._spec(values);
const errors2: IDiagnostic[] = errors ? [...errors] : [];
if (values?.tailwind !== void 0) {
const { tailwind } = values;
const valid = tailwind && typeof tailwind === 'object' && !(tailwind instanceof Array) &&
(tailwind.sources === void 0 || (tailwind.sources instanceof Array && tailwind.sources.every((source: unknown) => typeof source === 'string' && source)));
valid ? (output.tailwind = { sources: tailwind.sources }) : errors2.push({ code: 'TAILWIND_INVALID', message: 'The "tailwind" of the module manifest must be an object whose "sources" are paths relative to the module' });
}
return { values: output, errors: errors2, warnings };
}
async _build(request: IRequest, outputs: ProcessorOutputs): Promise<void> {
const { module } = this.conditional;
const directory = join(module.package.path, module.spec.path ?? '');
const settings = <ITailwindSettings>(<{ tailwind?: ITailwindSettings }>this.spec.values).tailwind;
const tailwind = new Tailwind(directory, module.package.path);
const read = new Set<string>();
const diagnostics: IDiagnostic[] = [];
for (const input of [...this.sources.inputs.values()].sort((a, b) => a.relative.file.localeCompare(b.relative.file))) {
if (Sass.partial(input.file)) continue;
const output = outputs.styles.obtain(input);
if (!input.valid) {
output.issues.push('errors', { code: 'SOURCE_ERROR', message: input.errors.join('; ') });
continue;
}
const relative = input.relative.file.replace(/\\/g, '/');
const compiled = Tailwind.uses(input.content)
? await tailwind.compile(input.file, input.content, settings, relative)
: Sass.compile(input.file, input.content, relative);
if (request !== this._request) return;
compiled.read.forEach(file => read.add(file));
compiled.diagnostics.forEach(diagnostic => output.issues.push('errors', diagnostic));
typeof compiled.code === 'string' && output.code.set({ code: compiled.code, map: compiled.map });
}
// The dependencies of this build are watched from now on; the ones of the previous build no longer read are released
try {
await this.#dependencies.update(read);
} catch (error) {
diagnostics.push({ code: 'STYLE_DEPENDENCY_ERROR', message: error.message });
}
if (request !== this._request) return;
this.#diagnostics = diagnostics;
}
destroy() {
super.destroy();
this.#dependencies.destroy();
}
}Lo que este procesador muestra del contrato:
- Entradas y configuración.
_specconserva las entradastailwindque declara el manifiesto, así que editarlas reprocesa el módulo; la ruta, los archivos y las exclusiones de las entradas los conserva la clase base. - Salidas y diagnósticos. Cada fuente produce una salida con su código y su mapa; un problema de una fuente es una incidencia de esa salida, con posición; un problema de la compilación es un diagnóstico del procesador, y el condicional informa ambos.
- Dependencias e invalidación. Lo que una compilación lee además de sus entradas (parciales, un tema, las fuentes escaneadas) se registra como dependencia y se vigila; una dependencia que una compilación posterior ya no lee se libera. Editar cualquiera de ellas invalida el procesador, que compila de nuevo; una compilación cuya solicitud ya no es la actual no publica nada.
- Liberación.
destroylibera las dependencias y llama a la clase base, que libera las fuentes y sus listeners.
Qué da el ensamblado a cada procesador del bundler ts
- El artefacto de un condicional importa el runtime que selecciona el paquete; una fuente escrita contra
@beyond-js/kernel/{bundle,core,styles}se ensambla contra ese runtime. - Una hoja de estilos por módulo, concatenada en orden de archivo a partir de las salidas de estilos, con su mapa; entregada junto al código.
- Un condicional
typespor módulo que verifica las fuentes semánticamente y emite la declaración pública del módulo; no es un artefacto ejecutable. - Un widget declarado en el manifiesto se registra antes de que el módulo se inicialice, con su elemento, sus atributos y si su paquete publica una hoja de estilos compartida.
Siguiente
Mira cómo se ven los artefactos en sus direcciones: URL e identidades.