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:

JSONpackage.json
{
	"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:

TypeScriptindex.ts
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' });
	}
}
TypeScriptconditional.ts
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.

TypeScriptindex.ts
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 });
	}
}
TypeScriptesm.ts
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.

TypeScriptindex.ts
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. _spec conserva las entradas tailwind que 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. destroy libera 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 types por 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.