aboutsummaryrefslogtreecommitdiff
path: root/src/js/builtins/BundlerPlugin.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/js/builtins/BundlerPlugin.ts')
-rw-r--r--src/js/builtins/BundlerPlugin.ts370
1 files changed, 370 insertions, 0 deletions
diff --git a/src/js/builtins/BundlerPlugin.ts b/src/js/builtins/BundlerPlugin.ts
new file mode 100644
index 000000000..831a6614e
--- /dev/null
+++ b/src/js/builtins/BundlerPlugin.ts
@@ -0,0 +1,370 @@
+import type {
+ AnyFunction,
+ BuildConfig,
+ BunPlugin,
+ OnLoadCallback,
+ OnLoadResult,
+ OnLoadResultObject,
+ OnLoadResultSourceCode,
+ OnResolveCallback,
+ PluginBuilder,
+ PluginConstraints,
+} from "bun";
+
+// This API expects 4 functions:
+// It should be generic enough to reuse for Bun.plugin() eventually, too.
+interface BundlerPlugin {
+ onLoad: Map<string, [RegExp, OnLoadCallback][]>;
+ onResolve: Map<string, [RegExp, OnResolveCallback][]>;
+ onLoadAsync(
+ internalID,
+ sourceCode: string | Uint8Array | ArrayBuffer | DataView | null,
+ loaderKey: number | null,
+ ): void;
+ onResolveAsync(internalID, a, b, c): void;
+ addError(internalID, error, number): void;
+ addFilter(filter, namespace, number): void;
+}
+
+// Extra types
+type Setup = BunPlugin["setup"];
+type MinifyObj = Exclude<BuildConfig["minify"], boolean>;
+interface BuildConfigExt extends BuildConfig {
+ // we support esbuild-style entryPoints
+ entryPoints?: string[];
+ // plugins is guaranteed to not be null
+ plugins: BunPlugin[];
+}
+interface PluginBuilderExt extends PluginBuilder {
+ // these functions aren't implemented yet, so we dont publicly expose them
+ resolve: AnyFunction;
+ onStart: AnyFunction;
+ onEnd: AnyFunction;
+ onDispose: AnyFunction;
+ // we partially support initialOptions. it's read-only and a subset of
+ // all options mapped to their esbuild names
+ initialOptions: any;
+ // we set this to an empty object
+ esbuild: any;
+}
+
+export function runSetupFunction(this: BundlerPlugin, setup: Setup, config: BuildConfigExt) {
+ var onLoadPlugins = new Map<string, [RegExp, AnyFunction][]>();
+ var onResolvePlugins = new Map<string, [RegExp, AnyFunction][]>();
+
+ function validate(filterObject: PluginConstraints, callback, map) {
+ if (!filterObject || !$isObject(filterObject)) {
+ throw new TypeError('Expected an object with "filter" RegExp');
+ }
+
+ if (!callback || !$isCallable(callback)) {
+ throw new TypeError("callback must be a function");
+ }
+
+ var { filter, namespace = "file" } = filterObject;
+
+ if (!filter) {
+ throw new TypeError('Expected an object with "filter" RegExp');
+ }
+
+ if (!$isRegExpObject(filter)) {
+ throw new TypeError("filter must be a RegExp");
+ }
+
+ if (namespace && !(typeof namespace === "string")) {
+ throw new TypeError("namespace must be a string");
+ }
+
+ if ((namespace?.length ?? 0) === 0) {
+ namespace = "file";
+ }
+
+ if (!/^([/$a-zA-Z0-9_\\-]+)$/.test(namespace)) {
+ throw new TypeError("namespace can only contain $a-zA-Z0-9_\\-");
+ }
+
+ var callbacks = map.$get(namespace);
+
+ if (!callbacks) {
+ map.$set(namespace, [[filter, callback]]);
+ } else {
+ $arrayPush(callbacks, [filter, callback]);
+ }
+ }
+
+ function onLoad(filterObject, callback) {
+ validate(filterObject, callback, onLoadPlugins);
+ }
+
+ function onResolve(filterObject, callback) {
+ validate(filterObject, callback, onResolvePlugins);
+ }
+
+ const processSetupResult = () => {
+ var anyOnLoad = false,
+ anyOnResolve = false;
+
+ for (var [namespace, callbacks] of onLoadPlugins.entries()) {
+ for (var [filter] of callbacks) {
+ this.addFilter(filter, namespace, 1);
+ anyOnLoad = true;
+ }
+ }
+
+ for (var [namespace, callbacks] of onResolvePlugins.entries()) {
+ for (var [filter] of callbacks) {
+ this.addFilter(filter, namespace, 0);
+ anyOnResolve = true;
+ }
+ }
+
+ if (anyOnResolve) {
+ var onResolveObject = this.onResolve;
+ if (!onResolveObject) {
+ this.onResolve = onResolvePlugins;
+ } else {
+ for (var [namespace, callbacks] of onResolvePlugins.entries()) {
+ var existing = onResolveObject.$get(namespace) as [RegExp, AnyFunction][];
+
+ if (!existing) {
+ onResolveObject.$set(namespace, callbacks);
+ } else {
+ onResolveObject.$set(namespace, existing.concat(callbacks));
+ }
+ }
+ }
+ }
+
+ if (anyOnLoad) {
+ var onLoadObject = this.onLoad;
+ if (!onLoadObject) {
+ this.onLoad = onLoadPlugins;
+ } else {
+ for (var [namespace, callbacks] of onLoadPlugins.entries()) {
+ var existing = onLoadObject.$get(namespace) as [RegExp, AnyFunction][];
+
+ if (!existing) {
+ onLoadObject.$set(namespace, callbacks);
+ } else {
+ onLoadObject.$set(namespace, existing.concat(callbacks));
+ }
+ }
+ }
+ }
+
+ return anyOnLoad || anyOnResolve;
+ };
+
+ var setupResult = setup({
+ config: config,
+ onDispose: notImplementedIssueFn(2771, "On-dispose callbacks"),
+ onEnd: notImplementedIssueFn(2771, "On-end callbacks"),
+ onLoad,
+ onResolve,
+ onStart: notImplementedIssueFn(2771, "On-start callbacks"),
+ resolve: notImplementedIssueFn(2771, "build.resolve()"),
+ // esbuild's options argument is different, we provide some interop
+ initialOptions: {
+ ...config,
+ bundle: true,
+ entryPoints: config.entrypoints ?? config.entryPoints ?? [],
+ minify: typeof config.minify === "boolean" ? config.minify : false,
+ minifyIdentifiers: config.minify === true || (config.minify as MinifyObj)?.identifiers,
+ minifyWhitespace: config.minify === true || (config.minify as MinifyObj)?.whitespace,
+ minifySyntax: config.minify === true || (config.minify as MinifyObj)?.syntax,
+ outbase: config.root,
+ platform: config.target === "bun" ? "node" : config.target,
+ },
+ esbuild: {},
+ } satisfies PluginBuilderExt as PluginBuilder);
+
+ if (setupResult && $isPromise(setupResult)) {
+ if ($getPromiseInternalField(setupResult, $promiseFieldFlags) & $promiseStateFulfilled) {
+ setupResult = $getPromiseInternalField(setupResult, $promiseFieldReactionsOrResult);
+ } else {
+ return setupResult.$then(processSetupResult);
+ }
+ }
+
+ return processSetupResult();
+}
+
+export function runOnResolvePlugins(this: BundlerPlugin, specifier, inputNamespace, importer, internalID, kindId) {
+ // Must be kept in sync with ImportRecord.label
+ const kind = $ImportKindIdToLabel[kindId];
+
+ var promiseResult: any = (async (inputPath, inputNamespace, importer, kind) => {
+ var { onResolve, onLoad } = this;
+ var results = onResolve.$get(inputNamespace);
+ if (!results) {
+ this.onResolveAsync(internalID, null, null, null);
+ return null;
+ }
+
+ for (let [filter, callback] of results) {
+ if (filter.test(inputPath)) {
+ var result = callback({
+ path: inputPath,
+ importer,
+ namespace: inputNamespace,
+ // resolveDir
+ kind,
+ // pluginData
+ });
+
+ while (
+ result &&
+ $isPromise(result) &&
+ ($getPromiseInternalField(result, $promiseFieldFlags) & $promiseStateMask) === $promiseStateFulfilled
+ ) {
+ result = $getPromiseInternalField(result, $promiseFieldReactionsOrResult);
+ }
+
+ if (result && $isPromise(result)) {
+ result = await result;
+ }
+
+ if (!result || !$isObject(result)) {
+ continue;
+ }
+
+ var { path, namespace: userNamespace = inputNamespace, external } = result;
+ if (!(typeof path === "string") || !(typeof userNamespace === "string")) {
+ throw new TypeError("onResolve plugins must return an object with a string 'path' and string 'loader' field");
+ }
+
+ if (!path) {
+ continue;
+ }
+
+ if (!userNamespace) {
+ userNamespace = inputNamespace;
+ }
+ if (typeof external !== "boolean" && !$isUndefinedOrNull(external)) {
+ throw new TypeError('onResolve plugins "external" field must be boolean or unspecified');
+ }
+
+ if (!external) {
+ if (userNamespace === "file") {
+ if (process.platform !== "win32") {
+ if (path[0] !== "/" || path.includes("..")) {
+ throw new TypeError('onResolve plugin "path" must be absolute when the namespace is "file"');
+ }
+ } else {
+ // TODO: Windows
+ }
+ }
+ if (userNamespace === "dataurl") {
+ if (!path.startsWith("data:")) {
+ throw new TypeError('onResolve plugin "path" must start with "data:" when the namespace is "dataurl"');
+ }
+ }
+
+ if (userNamespace && userNamespace !== "file" && (!onLoad || !onLoad.$has(userNamespace))) {
+ throw new TypeError(`Expected onLoad plugin for namespace ${userNamespace} to exist`);
+ }
+ }
+ this.onResolveAsync(internalID, path, userNamespace, external);
+ return null;
+ }
+ }
+
+ this.onResolveAsync(internalID, null, null, null);
+ return null;
+ })(specifier, inputNamespace, importer, kind);
+
+ while (
+ promiseResult &&
+ $isPromise(promiseResult) &&
+ ($getPromiseInternalField(promiseResult, $promiseFieldFlags) & $promiseStateMask) === $promiseStateFulfilled
+ ) {
+ promiseResult = $getPromiseInternalField(promiseResult, $promiseFieldReactionsOrResult);
+ }
+
+ if (promiseResult && $isPromise(promiseResult)) {
+ promiseResult.then(
+ () => {},
+ e => {
+ this.addError(internalID, e, 0);
+ },
+ );
+ }
+}
+
+export function runOnLoadPlugins(this: BundlerPlugin, internalID, path, namespace, defaultLoaderId) {
+ const LOADERS_MAP = $LoaderLabelToId;
+ const loaderName = $LoaderIdToLabel[defaultLoaderId];
+
+ var promiseResult = (async (internalID, path, namespace, defaultLoader) => {
+ var results = this.onLoad.$get(namespace);
+ if (!results) {
+ this.onLoadAsync(internalID, null, null);
+ return null;
+ }
+
+ for (let [filter, callback] of results) {
+ if (filter.test(path)) {
+ var result = callback({
+ path,
+ namespace,
+ // suffix
+ // pluginData
+ loader: defaultLoader,
+ });
+
+ while (
+ result &&
+ $isPromise(result) &&
+ ($getPromiseInternalField(result, $promiseFieldFlags) & $promiseStateMask) === $promiseStateFulfilled
+ ) {
+ result = $getPromiseInternalField(result, $promiseFieldReactionsOrResult);
+ }
+
+ if (result && $isPromise(result)) {
+ result = await result;
+ }
+
+ if (!result || !$isObject(result)) {
+ continue;
+ }
+
+ var { contents, loader = defaultLoader } = result as OnLoadResultSourceCode & OnLoadResultObject;
+ if (!(typeof contents === "string") && !$isTypedArrayView(contents)) {
+ throw new TypeError('onLoad plugins must return an object with "contents" as a string or Uint8Array');
+ }
+
+ if (!(typeof loader === "string")) {
+ throw new TypeError('onLoad plugins must return an object with "loader" as a string');
+ }
+
+ const chosenLoader = LOADERS_MAP[loader];
+ if (chosenLoader === undefined) {
+ throw new TypeError(`Loader ${loader} is not supported.`);
+ }
+
+ this.onLoadAsync(internalID, contents, chosenLoader);
+ return null;
+ }
+ }
+
+ this.onLoadAsync(internalID, null, null);
+ return null;
+ })(internalID, path, namespace, loaderName);
+
+ while (
+ promiseResult &&
+ $isPromise(promiseResult) &&
+ ($getPromiseInternalField(promiseResult, $promiseFieldFlags) & $promiseStateMask) === $promiseStateFulfilled
+ ) {
+ promiseResult = $getPromiseInternalField(promiseResult, $promiseFieldReactionsOrResult);
+ }
+
+ if (promiseResult && $isPromise(promiseResult)) {
+ promiseResult.then(
+ () => {},
+ e => {
+ this.addError(internalID, e, 1);
+ },
+ );
+ }
+}