diff --git a/.yarnrc.yml b/.yarnrc.yml
deleted file mode 100644
index 3186f3f..0000000
--- a/.yarnrc.yml
+++ /dev/null
@@ -1 +0,0 @@
-nodeLinker: node-modules
diff --git a/src/common/module-loader/README.md b/src/common/module-loader/README.md
new file mode 100644
index 0000000..0a82c95
--- /dev/null
+++ b/src/common/module-loader/README.md
@@ -0,0 +1,42 @@
+
Module Loader
+
+A protocol to load Source Academy modules
+
+
+
+
+
+
+## Features
+- Protocol for loading Source Academy modules on the runner side, from a changeable module directory on the host side
+
+## Installation
+```bash
+yarn add @sourceacademy/common-module-loader
+# OR
+npm i @sourceacademy/common-module-loader
+# OR
+pnpm add @sourceacademy/common-module-loader
+```
+
+## Structure
+This package ([`@sourceacademy/common-module-loader`](https://github.com/source-academy/plugins/tree/main/src/common/module-loader)) contains the shared constants and types required by both the runner-side and web-side plugin implementations.
+
+These include:
+- The IDs and channel name the two plugins use to find each other (`RUNNER_ID`, `WEB_ID`, `CHANNEL_ID`)
+- The message type (`ModuleLoaderMessage`) sent between the two plugins
+
+## Usage
+Ideally, you should not need to use this package directly. Instead, use the runner-side plugin ([`@sourceacademy/runner-module-loader`](https://github.com/source-academy/plugins/tree/main/src/runner/module-loader)) or the web-side plugin ([`@sourceacademy/web-module-loader`](https://github.com/source-academy/plugins/tree/main/src/web/module-loader)).
+
+However, if you do need to use this package directly, you can import the constants and types as follows:
+
+```ts
+import type { ModuleLoaderMessage } from '@sourceacademy/common-module-loader';
+...
+```
+
+## Further reading
+- To load a module, use [`@sourceacademy/runner-module-loader`](https://github.com/source-academy/plugins/tree/main/src/runner/module-loader)
+- To provide the module directory URL, use [`@sourceacademy/web-module-loader`](https://github.com/source-academy/plugins/tree/main/src/web/module-loader)
+- The [plugins wiki](https://github.com/source-academy/plugins/wiki) covers how Conductor plugins communicate
diff --git a/src/common/module-loader/manifest.json b/src/common/module-loader/manifest.json
new file mode 100644
index 0000000..e263f73
--- /dev/null
+++ b/src/common/module-loader/manifest.json
@@ -0,0 +1,3 @@
+{
+ "type": "installable"
+}
diff --git a/src/common/module-loader/package.json b/src/common/module-loader/package.json
new file mode 100644
index 0000000..4790468
--- /dev/null
+++ b/src/common/module-loader/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "@sourceacademy/common-module-loader",
+ "version": "0.0.1",
+ "packageManager": "yarn@4.6.0",
+ "description": "The common types for loading modules in Source Academy",
+ "scripts": {
+ "build": "rollup -c",
+ "prepack": "yarn build"
+ },
+ "license": "ISC",
+ "files": [
+ "dist"
+ ],
+ "main": "dist/index.cjs",
+ "module": "dist/index.mjs",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.cjs",
+ "types": "./dist/index.d.ts"
+ }
+ },
+ "devDependencies": {
+ "@rollup/plugin-node-resolve": "^16.0.3",
+ "@rollup/plugin-terser": "^1.0.0",
+ "@rollup/plugin-typescript": "^12.3.0",
+ "rollup": "^4.60.2",
+ "tslib": "^2.8.1",
+ "typescript": "^6.0.3",
+ "vitest": "^4.1.9"
+ }
+}
diff --git a/src/common/module-loader/rollup.config.mjs b/src/common/module-loader/rollup.config.mjs
new file mode 100644
index 0000000..238a07c
--- /dev/null
+++ b/src/common/module-loader/rollup.config.mjs
@@ -0,0 +1,21 @@
+import nodeResolve from "@rollup/plugin-node-resolve";
+import terser from "@rollup/plugin-terser";
+import typescript from "@rollup/plugin-typescript";
+
+/**
+ * @type {import('rollup').RollupOptions}
+ */
+export default {
+ input: "src/index.ts",
+ output: [
+ {
+ file: "dist/index.cjs",
+ format: "cjs",
+ },
+ {
+ file: "dist/index.mjs",
+ format: "esm",
+ },
+ ],
+ plugins: [nodeResolve(), typescript(), terser()],
+};
diff --git a/src/common/module-loader/src/index.ts b/src/common/module-loader/src/index.ts
new file mode 100644
index 0000000..3ca4f9d
--- /dev/null
+++ b/src/common/module-loader/src/index.ts
@@ -0,0 +1,27 @@
+export const WEB_ID = "__web_module_loader";
+export const RUNNER_ID = "__runner_module_loader";
+
+export const CHANNEL_ID = "module_config";
+
+export enum ModuleLoaderMessageType {
+ REQUEST_MODULE = "request_module",
+ MODULE_RESPONSE = "module_response",
+ MODULE_ERROR = "module_error",
+}
+
+export type ModuleLoaderMessage =
+ | {
+ type: ModuleLoaderMessageType.REQUEST_MODULE;
+ moduleName: string;
+ }
+ | {
+ type: ModuleLoaderMessageType.MODULE_RESPONSE;
+ moduleName: string;
+ moduleURL: string;
+ tabs: string[];
+ }
+ | {
+ type: ModuleLoaderMessageType.MODULE_ERROR;
+ moduleName: string;
+ error: string;
+ };
diff --git a/src/common/module-loader/tsconfig.json b/src/common/module-loader/tsconfig.json
new file mode 100644
index 0000000..c7482f9
--- /dev/null
+++ b/src/common/module-loader/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.json",
+ "exclude": ["./dist"],
+ "include": ["./src"],
+ "compilerOptions": {
+ "declaration": true,
+ "outDir": "./dist",
+ "rootDir": "./src"
+ }
+}
diff --git a/src/runner/module-loader/README.md b/src/runner/module-loader/README.md
new file mode 100644
index 0000000..015b87e
--- /dev/null
+++ b/src/runner/module-loader/README.md
@@ -0,0 +1,46 @@
+Module Loader
+
+Runner-side plugin for loading Source Academy modules
+
+
+
+
+
+
+## Features
+The plugin provides an interface to load modules with a module ID
+
+## Installation
+```bash
+yarn add @sourceacademy/runner-module-loader
+# OR
+npm i @sourceacademy/runner-module-loader
+# OR
+pnpm add @sourceacademy/runner-module-loader
+```
+
+## Structure
+This package ([`@sourceacademy/runner-module-loader`](https://github.com/source-academy/plugins/tree/main/src/runner/module-loader)) contains the `ModuleLoaderRunnerPlugin` class — a Conductor runner plugin that an evaluator can call to load modules.
+
+### API Reference
+| Name | Description |
+|------|-------------|
+| `instance` | The singleton instance of the plugin. |
+| `async requestModule(moduleName: string): Promise` | Request a module by name from the host plugin. Rejects with an error if the module is not found. |
+
+## Usage
+After installation, import `ModuleLoaderRunnerPlugin` and register it with the Conductor evaluator. When evaluating `import` statements, call `requestModule` to load the module.
+
+```ts
+...
+const pluginObj = await ModuleLoaderRunnerPlugin.instance.requestModule(moduleName);
+context.nativeStorage.loadedModules[moduleName] = Object.fromEntries(
+ pluginObj?.exports.map(t => [t.symbol, t]) || [],
+);
+...
+```
+
+## Further reading
+- For the shared protocol types and IDs, see [`@sourceacademy/common-module-loader`](https://github.com/source-academy/plugins/tree/main/src/common/module-loader)
+- For the host-side plugin that accepts the module directory URL, see [`@sourceacademy/web-module-loader`](https://github.com/source-academy/plugins/tree/main/src/web/module-loader)
+- The [plugins wiki](https://github.com/source-academy/plugins/wiki) covers how Conductor plugins communicate
diff --git a/src/runner/module-loader/manifest.json b/src/runner/module-loader/manifest.json
new file mode 100644
index 0000000..e263f73
--- /dev/null
+++ b/src/runner/module-loader/manifest.json
@@ -0,0 +1,3 @@
+{
+ "type": "installable"
+}
diff --git a/src/runner/module-loader/package.json b/src/runner/module-loader/package.json
new file mode 100644
index 0000000..2db5301
--- /dev/null
+++ b/src/runner/module-loader/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@sourceacademy/runner-module-loader",
+ "version": "0.0.1",
+ "packageManager": "yarn@4.12.0",
+ "description": "The runner plugin for the module loader in Source Academy",
+ "scripts": {
+ "build": "rollup -c",
+ "prepack": "yarn build"
+ },
+ "license": "ISC",
+ "files": [
+ "dist"
+ ],
+ "main": "dist/index.cjs",
+ "module": "dist/index.mjs",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.cjs",
+ "types": "./dist/index.d.ts"
+ }
+ },
+ "peerDependencies": {
+ "@sourceacademy/conductor": ">=0.3.0"
+ },
+ "devDependencies": {
+ "@rollup/plugin-node-resolve": "^16.0.3",
+ "@rollup/plugin-terser": "^1.0.0",
+ "@rollup/plugin-typescript": "^12.3.0",
+ "@sourceacademy/common-module-loader": "workspace:*",
+ "@sourceacademy/conductor": ">=0.3.0",
+ "@sourceacademy/runner-remote-execution": "workspace:*",
+ "@vitest/coverage-istanbul": "^4.1.9",
+ "rollup": "^4.60.2",
+ "tslib": "^2.8.1",
+ "typescript": "^6.0.3",
+ "vitest": "^4.1.9"
+ }
+}
diff --git a/src/runner/module-loader/rollup.config.mjs b/src/runner/module-loader/rollup.config.mjs
new file mode 100644
index 0000000..3f56b30
--- /dev/null
+++ b/src/runner/module-loader/rollup.config.mjs
@@ -0,0 +1,19 @@
+import nodeResolve from "@rollup/plugin-node-resolve";
+import terser from "@rollup/plugin-terser";
+import typescript from "@rollup/plugin-typescript";
+
+/** @type {import('rollup').RollupOptions} */
+export default {
+ input: "src/index.ts",
+ output: [
+ {
+ file: "dist/index.cjs",
+ format: "cjs",
+ },
+ {
+ file: "dist/index.mjs",
+ format: "esm",
+ },
+ ],
+ plugins: [nodeResolve(), typescript(), terser()],
+};
diff --git a/src/runner/module-loader/src/__tests__/runner.test.ts b/src/runner/module-loader/src/__tests__/runner.test.ts
new file mode 100644
index 0000000..d5d5c56
--- /dev/null
+++ b/src/runner/module-loader/src/__tests__/runner.test.ts
@@ -0,0 +1,177 @@
+import { afterEach, describe, expect, test, vi, type Mock, type MockedFunction } from "vitest";
+import { ModuleLoaderRunnerPlugin } from "..";
+import {
+ CHANNEL_ID,
+ ModuleLoaderMessageType,
+ RUNNER_ID,
+ type ModuleLoaderMessage,
+} from "@sourceacademy/common-module-loader";
+import type { IChannel, IConduit } from "@sourceacademy/conductor/conduit";
+import type { IModulePlugin } from "@sourceacademy/conductor/module";
+import type { IInterfacableEvaluator, IRunnerPlugin } from "@sourceacademy/conductor/runner";
+import type { PluginClass } from "@sourceacademy/conductor/conduit";
+type ChannelSubscriber = (msg: ModuleLoaderMessage) => void | Promise;
+
+type TestChannel = IChannel & {
+ send: Mock<(msg: ModuleLoaderMessage) => void>;
+ subscribe: Mock<(handler: ChannelSubscriber) => void>;
+ unsubscribe: Mock<(handler: ChannelSubscriber) => void>;
+};
+
+const mockBundleURL = `data:text/javascript;charset=utf-8,${encodeURIComponent(`
+class MockModulePlugin {}
+export default () => ({ default: MockModulePlugin });
+`)}`;
+
+const makeChannel = (
+ getResponse?: (msg: ModuleLoaderMessage) => ModuleLoaderMessage | undefined,
+): TestChannel => {
+ let subscriber: ChannelSubscriber | undefined;
+ const send = vi.fn((msg: ModuleLoaderMessage) => {
+ const response = getResponse?.(msg);
+ if (response) {
+ queueMicrotask(() => subscriber?.(response));
+ }
+ });
+ const subscribe = vi.fn((handler: ChannelSubscriber) => {
+ subscriber = handler;
+ });
+ const unsubscribe = vi.fn((handler: ChannelSubscriber) => {
+ if (subscriber === handler) {
+ subscriber = undefined;
+ }
+ });
+ return {
+ name: CHANNEL_ID,
+ send,
+ subscribe,
+ unsubscribe,
+ close: vi.fn(),
+ };
+};
+
+const makeConductor = () => {
+ const initialise = vi.fn<() => Promise>().mockResolvedValue(undefined);
+ const pluginObj = { initialise } as unknown as IModulePlugin;
+ const registerPlugin: MockedFunction<
+ (
+ pluginClass: PluginClass,
+ evaluator: IInterfacableEvaluator,
+ options: { tabs: string[]; loadTab: (tabName: string) => void },
+ ) => IModulePlugin
+ > = vi.fn(() => pluginObj);
+ const hostLoadPlugin = vi.fn();
+ const conductor = {
+ registerPlugin,
+ hostLoadPlugin,
+ } as unknown as IRunnerPlugin;
+ return { conductor, registerPlugin, hostLoadPlugin, initialise, pluginObj };
+};
+
+const makePlugin = (channel = makeChannel()) => {
+ const evaluator = {} as IInterfacableEvaluator;
+ const { conductor, registerPlugin, hostLoadPlugin, initialise, pluginObj } = makeConductor();
+ const plugin = new ModuleLoaderRunnerPlugin({} as IConduit, [channel], conductor, evaluator);
+ return {
+ plugin,
+ channel,
+ evaluator,
+ registerPlugin,
+ hostLoadPlugin,
+ initialise,
+ pluginObj,
+ };
+};
+
+afterEach(() => {
+ ModuleLoaderRunnerPlugin.instance = null;
+});
+
+describe("plugin identity", () => {
+ test("id is RUNNER_ID", () => {
+ expect(makePlugin().plugin.id).toBe(RUNNER_ID);
+ });
+
+ test("channelAttach declares the module loader channel", () => {
+ expect(ModuleLoaderRunnerPlugin.channelAttach).toEqual([CHANNEL_ID]);
+ });
+
+ test("constructor sets the singleton instance", () => {
+ const { plugin } = makePlugin();
+ expect(ModuleLoaderRunnerPlugin.instance).toBe(plugin);
+ });
+});
+
+describe("requestModule", () => {
+ test("requests a module, imports the returned bundle, registers it, and initialises it", async () => {
+ const tabs = ["ChartTab", "SettingsTab"];
+ const channel = makeChannel(msg => {
+ if (msg.type !== ModuleLoaderMessageType.REQUEST_MODULE) {
+ return undefined;
+ }
+ return {
+ type: ModuleLoaderMessageType.MODULE_RESPONSE,
+ moduleName: msg.moduleName,
+ moduleURL: mockBundleURL,
+ tabs,
+ };
+ });
+ const { plugin, evaluator, registerPlugin, initialise, pluginObj } = makePlugin(channel);
+
+ await expect(plugin.requestModule("chart")).resolves.toBe(pluginObj);
+
+ expect(channel.subscribe).toHaveBeenCalledOnce();
+ expect(channel.send).toHaveBeenCalledWith({
+ type: ModuleLoaderMessageType.REQUEST_MODULE,
+ moduleName: "chart",
+ });
+ expect(channel.unsubscribe).toHaveBeenCalledOnce();
+ expect(registerPlugin).toHaveBeenCalledOnce();
+ const [pluginClass, receivedEvaluator, options] = registerPlugin.mock.calls[0];
+ expect(pluginClass).toEqual(expect.any(Function));
+ expect(pluginClass.name).toBe("MockModulePlugin");
+ expect(receivedEvaluator).toBe(evaluator);
+ expect(options).toMatchObject({ tabs });
+ expect(initialise).toHaveBeenCalledOnce();
+ });
+
+ test("loadTab delegates to the conductor for declared tabs only", async () => {
+ const channel = makeChannel(msg => {
+ if (msg.type !== ModuleLoaderMessageType.REQUEST_MODULE) {
+ return undefined;
+ }
+ return {
+ type: ModuleLoaderMessageType.MODULE_RESPONSE,
+ moduleName: msg.moduleName,
+ moduleURL: mockBundleURL,
+ tabs: ["ChartTab"],
+ };
+ });
+ const { plugin, registerPlugin, hostLoadPlugin } = makePlugin(channel);
+
+ await plugin.requestModule("chart");
+ const options = registerPlugin.mock.calls[0][2];
+
+ options.loadTab("ChartTab");
+ expect(hostLoadPlugin).toHaveBeenCalledWith("ChartTab");
+ expect(() => options.loadTab("MissingTab")).toThrow("Tab MissingTab not found in module chart");
+ });
+
+ test("rejects when the mocked web side returns a module error", async () => {
+ const channel = makeChannel(msg => {
+ if (msg.type !== ModuleLoaderMessageType.REQUEST_MODULE) {
+ return undefined;
+ }
+ return {
+ type: ModuleLoaderMessageType.MODULE_ERROR,
+ moduleName: msg.moduleName,
+ error: "Module not found: missing",
+ };
+ });
+ const { plugin, registerPlugin } = makePlugin(channel);
+
+ await expect(plugin.requestModule("missing")).rejects.toThrow("Module not found: missing");
+ expect(channel.unsubscribe).toHaveBeenCalledOnce();
+ expect(registerPlugin).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/runner/module-loader/src/index.ts b/src/runner/module-loader/src/index.ts
new file mode 100644
index 0000000..ca573f1
--- /dev/null
+++ b/src/runner/module-loader/src/index.ts
@@ -0,0 +1,72 @@
+import {
+ CHANNEL_ID,
+ ModuleLoaderMessageType,
+ RUNNER_ID,
+ type ModuleLoaderMessage,
+} from "@sourceacademy/common-module-loader";
+import {
+ checkIsPluginClass,
+ type IChannel,
+ type IConduit,
+ type IPlugin,
+} from "@sourceacademy/conductor/conduit";
+import type { IModulePlugin } from "@sourceacademy/conductor/module";
+import type { IInterfacableEvaluator, IRunnerPlugin } from "@sourceacademy/conductor/runner";
+
+export class ModuleLoaderRunnerPlugin implements IPlugin {
+ readonly id: string = RUNNER_ID;
+ static readonly channelAttach = [CHANNEL_ID];
+ private readonly __moduleRequestChannel: IChannel;
+ private readonly __conductor: IRunnerPlugin;
+ private readonly __evaluator: IInterfacableEvaluator;
+ static instance: ModuleLoaderRunnerPlugin | null = null;
+ constructor(
+ conduit: IConduit,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ [moduleRequestChannel]: IChannel[],
+ conductor: IRunnerPlugin,
+ evaluator: IInterfacableEvaluator,
+ ) {
+ this.__moduleRequestChannel = moduleRequestChannel;
+ this.__conductor = conductor;
+ this.__evaluator = evaluator;
+ ModuleLoaderRunnerPlugin.instance = this;
+ }
+ async requestModule(moduleName: string): Promise {
+ return new Promise((resolve, reject) => {
+ const handleResponse = async (msg: ModuleLoaderMessage) => {
+ try {
+ if (msg.moduleName !== moduleName) return;
+ this.__moduleRequestChannel.unsubscribe(handleResponse);
+ if (msg.type === ModuleLoaderMessageType.MODULE_RESPONSE) {
+ const plugin = await import(msg.moduleURL).then(module => {
+ return module.default(() => {}).default;
+ });
+ const pluginObj = this.__conductor.registerPlugin(plugin, this.__evaluator, {
+ tabs: msg.tabs,
+ loadTab: (tabName: string) => {
+ if (!msg.tabs.includes(tabName)) {
+ throw new Error(`Tab ${tabName} not found in module ${moduleName}`);
+ }
+ this.__conductor.hostLoadPlugin(tabName);
+ },
+ }) as IModulePlugin;
+ await pluginObj?.initialise();
+ resolve(pluginObj);
+ } else if (msg.type === ModuleLoaderMessageType.MODULE_ERROR) {
+ reject(new Error(msg.error));
+ }
+ } catch (error) {
+ this.__moduleRequestChannel.unsubscribe(handleResponse);
+ reject(error);
+ }
+ };
+ this.__moduleRequestChannel.subscribe(handleResponse);
+ this.__moduleRequestChannel.send({
+ type: ModuleLoaderMessageType.REQUEST_MODULE,
+ moduleName,
+ });
+ });
+ }
+}
+checkIsPluginClass(ModuleLoaderRunnerPlugin);
diff --git a/src/runner/module-loader/tsconfig.json b/src/runner/module-loader/tsconfig.json
new file mode 100644
index 0000000..c7482f9
--- /dev/null
+++ b/src/runner/module-loader/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.json",
+ "exclude": ["./dist"],
+ "include": ["./src"],
+ "compilerOptions": {
+ "declaration": true,
+ "outDir": "./dist",
+ "rootDir": "./src"
+ }
+}
diff --git a/src/web/module-loader/README.md b/src/web/module-loader/README.md
new file mode 100644
index 0000000..70d66cf
--- /dev/null
+++ b/src/web/module-loader/README.md
@@ -0,0 +1,67 @@
+ Module Loader
+
+Host-side plugin to load Source Academy modules
+
+
+
+
+
+
+## Features
+- Provides an interface to load modules with a module ID, as well as the location of tabs on the host-side
+- Accepts a module directory URL from the host app
+
+## Installation
+To install the package, use:
+```bash
+yarn add @sourceacademy/web-module-loader
+# OR
+npm i @sourceacademy/web-module-loader
+# OR
+pnpm add @sourceacademy/web-module-loader
+```
+
+## Structure
+This package ([`@sourceacademy/web-module-loader`](https://github.com/source-academy/plugins/tree/main/src/web/module-loader)) contains the `ModuleLoaderWebPlugin` class — a Conductor host plugin that subscribes to a Conductor channel and delivers module information to the host app.
+
+### API Reference
+| Name | Description |
+|------|-------------|
+| `instance` | The singleton instance of the plugin. |
+| `onModuleDirectoryURLChange(newUrl: string): void` | To be called during module directory URL changes. |
+| `getModuleTabLocation(tabName: string): string | null` | Returns the location of the specified module tab, or null if not found. |
+
+## Usage
+During the host-side Conductor initialisation, register the class with the conduit.
+
+```ts
+...
+hostPlugin.registerPlugin(ModuleLoaderWebPlugin);
+ModuleLoaderWebPlugin.instance.onModuleDirectoryURLChange(moduleDirectoryURL);
+...
+```
+
+Then, on module directory changes, call `onModuleDirectoryURLChange` to update the module directory URL.
+
+```ts
+export const flagDirectoryModulesUrl = createFeatureFlag(
+ 'directory.modules.url',
+ 'https://source-academy.github.io/modules-conductor/modules.json',
+ 'The URL where the module directory may be found.',
+ // eslint-disable-next-line require-yield
+ function* (url: string) {
+ ModuleLoaderWebPlugin.instance?.onModuleDirectoryURLChange(url);
+ },
+);
+```
+
+To receive the location of a module tab, call `getModuleTabLocation` with the tab name.
+
+```ts
+const tabLocation = ModuleLoaderWebPlugin.instance.getModuleTabLocation(tabName);
+```
+
+## Further reading
+- For the shared protocol types and IDs, see [`@sourceacademy/common-module-loader`](https://github.com/source-academy/plugins/tree/main/src/common/module-loader)
+- For the runner-side plugin that sends snapshots, see [`@sourceacademy/runner-module-loader`](https://github.com/source-academy/plugins/tree/main/src/runner/module-loader)
+- The [plugins wiki](https://github.com/source-academy/plugins/wiki) covers how Conductor plugins communicate
diff --git a/src/web/module-loader/manifest.json b/src/web/module-loader/manifest.json
new file mode 100644
index 0000000..e263f73
--- /dev/null
+++ b/src/web/module-loader/manifest.json
@@ -0,0 +1,3 @@
+{
+ "type": "installable"
+}
diff --git a/src/web/module-loader/package.json b/src/web/module-loader/package.json
new file mode 100644
index 0000000..849d7b6
--- /dev/null
+++ b/src/web/module-loader/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@sourceacademy/web-module-loader",
+ "version": "0.0.2",
+ "packageManager": "yarn@4.6.0",
+ "description": "The web plugin for the module loader in Source Academy",
+ "scripts": {
+ "build": "rollup -c",
+ "prepack": "yarn build"
+ },
+ "license": "ISC",
+ "publishConfig": {
+ "access": "public"
+ },
+ "files": [
+ "dist"
+ ],
+ "main": "dist/index.cjs",
+ "module": "dist/index.mjs",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.cjs",
+ "types": "./dist/index.d.ts"
+ }
+ },
+ "peerDependencies": {
+ "@sourceacademy/conductor": ">=0.3.0"
+ },
+ "devDependencies": {
+ "@rollup/plugin-commonjs": "^29.0.3",
+ "@rollup/plugin-node-resolve": "^16.0.3",
+ "@rollup/plugin-terser": "^1.0.0",
+ "@rollup/plugin-typescript": "^12.3.0",
+ "@sourceacademy/common-module-loader": "workspace:*",
+ "@sourceacademy/common-tabs": "workspace:^",
+ "@sourceacademy/conductor": ">=0.3.0",
+ "rollup": "^4.60.2",
+ "tslib": "^2.8.1",
+ "typescript": "^6.0.3",
+ "vitest": "^4.1.9"
+ }
+}
diff --git a/src/web/module-loader/rollup.config.mjs b/src/web/module-loader/rollup.config.mjs
new file mode 100644
index 0000000..06a3173
--- /dev/null
+++ b/src/web/module-loader/rollup.config.mjs
@@ -0,0 +1,22 @@
+import nodeResolve from "@rollup/plugin-node-resolve";
+import terser from "@rollup/plugin-terser";
+import typescript from "@rollup/plugin-typescript";
+import commonjs from "@rollup/plugin-commonjs";
+/**
+ * @type {import('rollup').RollupOptions}
+ */
+export default {
+ input: "src/index.ts",
+ output: [
+ {
+ file: "dist/index.cjs",
+ format: "cjs",
+ },
+ {
+ file: "dist/index.mjs",
+ format: "esm",
+ },
+ ],
+ plugins: [nodeResolve(), commonjs(), typescript(), terser()],
+ external: ["react", "react-dom", "react/jsx-runtime"],
+};
diff --git a/src/web/module-loader/src/__tests__/web.test.ts b/src/web/module-loader/src/__tests__/web.test.ts
new file mode 100644
index 0000000..a4104c4
--- /dev/null
+++ b/src/web/module-loader/src/__tests__/web.test.ts
@@ -0,0 +1,201 @@
+import { afterEach, describe, expect, test, vi, type Mock } from "vitest";
+import { ModuleLoaderWebPlugin } from "..";
+import {
+ CHANNEL_ID,
+ ModuleLoaderMessageType,
+ WEB_ID,
+ type ModuleLoaderMessage,
+} from "@sourceacademy/common-module-loader";
+import type { IChannel, IConduit } from "@sourceacademy/conductor/conduit";
+
+type ModuleDirectory = Record;
+type ChannelSubscriber = (msg: ModuleLoaderMessage) => void;
+
+type TestChannel = IChannel & {
+ send: Mock<(msg: ModuleLoaderMessage) => void>;
+ subscribe: Mock<(handler: ChannelSubscriber) => void>;
+ emit: (msg: ModuleLoaderMessage) => void;
+};
+
+const moduleDirectoryURL = "/mock/modules.json";
+const moduleDirectory: ModuleDirectory = {
+ chart: { tabs: ["ChartTab", "SettingsTab"] },
+ "!!!": { tabs: [] },
+};
+
+const makeChannel = (): TestChannel => {
+ let subscriber: ChannelSubscriber | undefined;
+ const send = vi.fn<(msg: ModuleLoaderMessage) => void>();
+ const subscribe = vi.fn((handler: ChannelSubscriber) => {
+ subscriber = handler;
+ });
+ const emit = (msg: ModuleLoaderMessage) => subscriber?.(msg);
+ return {
+ name: CHANNEL_ID,
+ send,
+ subscribe,
+ unsubscribe: vi.fn(),
+ close: vi.fn(),
+ emit,
+ };
+};
+
+const makePlugin = (channel = makeChannel()) => {
+ const plugin = new ModuleLoaderWebPlugin({} as IConduit, [channel]);
+ return { plugin, channel };
+};
+
+const mockFetch = (directory = moduleDirectory) => {
+ const json = vi.fn().mockResolvedValue(directory);
+ const fetch = vi.fn().mockResolvedValue({ json });
+ vi.stubGlobal("fetch", fetch);
+ return { fetch, json };
+};
+
+const loadDirectory = async (plugin: ModuleLoaderWebPlugin) => {
+ plugin.onModuleDirectoryURLChange(moduleDirectoryURL);
+ await vi.waitFor(() => {
+ expect(plugin.getModuleTabLocation("ChartTab")).toBe("/mock/tabs/ChartTab.js");
+ });
+};
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ ModuleLoaderWebPlugin.instance = null;
+});
+
+describe("plugin identity", () => {
+ test("id is WEB_ID", () => {
+ expect(makePlugin().plugin.id).toBe(WEB_ID);
+ });
+
+ test("channelAttach declares the module loader channel", () => {
+ expect(ModuleLoaderWebPlugin.channelAttach).toEqual([CHANNEL_ID]);
+ });
+
+ test("constructor sets the singleton instance", () => {
+ const { plugin } = makePlugin();
+ expect(ModuleLoaderWebPlugin.instance).toBe(plugin);
+ });
+});
+
+describe("module directory loading", () => {
+ test("fetches and stores the configured module directory URL", async () => {
+ const { plugin } = makePlugin();
+ const { fetch, json } = mockFetch();
+
+ await loadDirectory(plugin);
+
+ expect(fetch).toHaveBeenCalledWith(moduleDirectoryURL);
+ expect(json).toHaveBeenCalledOnce();
+ });
+
+ test("does not fetch the same loaded directory URL twice", async () => {
+ const { plugin } = makePlugin();
+ const { fetch } = mockFetch();
+
+ await loadDirectory(plugin);
+ plugin.onModuleDirectoryURLChange(moduleDirectoryURL);
+
+ expect(fetch).toHaveBeenCalledOnce();
+ });
+});
+
+describe("request handling", () => {
+ test("returns an error when the directory has not loaded yet", () => {
+ const { channel } = makePlugin();
+
+ channel.emit({
+ type: ModuleLoaderMessageType.REQUEST_MODULE,
+ moduleName: "chart",
+ });
+
+ expect(channel.send).toHaveBeenCalledWith({
+ type: ModuleLoaderMessageType.MODULE_ERROR,
+ moduleName: "chart",
+ error: "Module directory not loaded yet",
+ });
+ });
+
+ test("responds with the bundle URL and tabs for a known module", async () => {
+ const { plugin, channel } = makePlugin();
+ mockFetch();
+ await loadDirectory(plugin);
+
+ channel.emit({
+ type: ModuleLoaderMessageType.REQUEST_MODULE,
+ moduleName: "chart",
+ });
+
+ expect(channel.send).toHaveBeenCalledWith({
+ type: ModuleLoaderMessageType.MODULE_RESPONSE,
+ moduleName: "chart",
+ moduleURL: "/mock/bundles/chart.js",
+ tabs: ["ChartTab", "SettingsTab"],
+ });
+ });
+
+ test("returns an error for an unknown module", async () => {
+ const { plugin, channel } = makePlugin();
+ mockFetch();
+ await loadDirectory(plugin);
+
+ channel.emit({
+ type: ModuleLoaderMessageType.REQUEST_MODULE,
+ moduleName: "missing",
+ });
+
+ expect(channel.send).toHaveBeenCalledWith({
+ type: ModuleLoaderMessageType.MODULE_ERROR,
+ moduleName: "missing",
+ error: "Module not found: missing",
+ });
+ });
+
+ test("returns an error for an invalid module name present in the directory", async () => {
+ const { plugin, channel } = makePlugin();
+ mockFetch();
+ await loadDirectory(plugin);
+
+ channel.emit({
+ type: ModuleLoaderMessageType.REQUEST_MODULE,
+ moduleName: "!!!",
+ });
+
+ expect(channel.send).toHaveBeenCalledWith({
+ type: ModuleLoaderMessageType.MODULE_ERROR,
+ moduleName: "!!!",
+ error: "Invalid module name: !!!",
+ });
+ });
+
+ test("ignores non-request messages", () => {
+ const { channel } = makePlugin();
+
+ channel.emit({
+ type: ModuleLoaderMessageType.MODULE_RESPONSE,
+ moduleName: "chart",
+ moduleURL: "/mock/bundles/chart.js",
+ tabs: [],
+ });
+
+ expect(channel.send).not.toHaveBeenCalled();
+ });
+});
+
+describe("tab locations", () => {
+ test("returns null before the module directory has loaded", () => {
+ const { plugin } = makePlugin();
+
+ expect(plugin.getModuleTabLocation("ChartTab")).toBeNull();
+ });
+
+ test("returns tab URLs for known tabs and null for unknown tabs", async () => {
+ const { plugin } = makePlugin();
+ mockFetch();
+ await loadDirectory(plugin);
+
+ expect(plugin.getModuleTabLocation("ChartTab")).toBe("/mock/tabs/ChartTab.js");
+ expect(plugin.getModuleTabLocation("MissingTab")).toBeNull();
+ });
+});
diff --git a/src/web/module-loader/src/index.ts b/src/web/module-loader/src/index.ts
new file mode 100644
index 0000000..4f2fdcb
--- /dev/null
+++ b/src/web/module-loader/src/index.ts
@@ -0,0 +1,101 @@
+import {
+ CHANNEL_ID,
+ ModuleLoaderMessageType,
+ WEB_ID,
+ type ModuleLoaderMessage,
+} from "@sourceacademy/common-module-loader";
+import {
+ type IPlugin,
+ type IChannel,
+ type IConduit,
+ checkIsPluginClass,
+} from "@sourceacademy/conductor/conduit";
+
+type ModuleDirectoryBundle = {
+ tabs: string[];
+};
+type ModuleDirectory = Record;
+export class ModuleLoaderWebPlugin implements IPlugin {
+ readonly id: string = WEB_ID;
+ static readonly channelAttach = [CHANNEL_ID];
+ private readonly __moduleRequestChannel: IChannel;
+
+ static instance: ModuleLoaderWebPlugin | null = null;
+ private moduleDirectoryURL: string | null = null;
+ private moduleDirectory: ModuleDirectory | null = null;
+
+ constructor(
+ _conduit: IConduit,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ [moduleRequestChannel]: IChannel[],
+ ) {
+ this.__moduleRequestChannel = moduleRequestChannel;
+ ModuleLoaderWebPlugin.instance = this;
+ this.__moduleRequestChannel.subscribe(message => {
+ if (message.type !== ModuleLoaderMessageType.REQUEST_MODULE) return;
+ if (this.moduleDirectory === null || this.moduleDirectoryURL === null) {
+ return this.__moduleRequestChannel.send({
+ type: ModuleLoaderMessageType.MODULE_ERROR,
+ moduleName: message.moduleName,
+ error: "Module directory not loaded yet",
+ });
+ }
+ if (!(message.moduleName in this.moduleDirectory)) {
+ return this.__moduleRequestChannel.send({
+ type: ModuleLoaderMessageType.MODULE_ERROR,
+ moduleName: message.moduleName,
+ error: `Module not found: ${message.moduleName}`,
+ });
+ }
+ if (!/^[a-zA-Z0-9_-]+$/.test(message.moduleName)) {
+ return this.__moduleRequestChannel.send({
+ type: ModuleLoaderMessageType.MODULE_ERROR,
+ moduleName: message.moduleName,
+ error: `Invalid module name: ${message.moduleName}`,
+ });
+ }
+ const moduleBaseUrl = this.moduleDirectoryURL.slice(
+ 0,
+ this.moduleDirectoryURL.lastIndexOf("/") + 1,
+ );
+ return this.__moduleRequestChannel.send({
+ type: ModuleLoaderMessageType.MODULE_RESPONSE,
+ moduleName: message.moduleName,
+ moduleURL: moduleBaseUrl + "bundles/" + message.moduleName + ".js",
+ tabs: this.moduleDirectory[message.moduleName].tabs,
+ });
+ });
+ }
+
+ onModuleDirectoryURLChange(newURL: string): void {
+ if (newURL === this.moduleDirectoryURL && this.moduleDirectory) {
+ return;
+ }
+ this.moduleDirectoryURL = newURL;
+ fetch(newURL)
+ .then(response => response.json())
+ .then(data => {
+ this.moduleDirectory = data;
+ })
+ .catch(error => {
+ console.error("Failed to load module directory:", error);
+ });
+ }
+
+ getModuleTabLocation(tabName: string): string | null {
+ if (!this.moduleDirectory || !this.moduleDirectoryURL) {
+ return null;
+ }
+ for (const moduleName in this.moduleDirectory) {
+ if (this.moduleDirectory[moduleName].tabs.includes(tabName)) {
+ const moduleBaseUrl = this.moduleDirectoryURL.slice(
+ 0,
+ this.moduleDirectoryURL.lastIndexOf("/") + 1,
+ );
+ return moduleBaseUrl + "tabs/" + tabName + ".js";
+ }
+ }
+ return null;
+ }
+}
+checkIsPluginClass(ModuleLoaderWebPlugin);
diff --git a/src/web/module-loader/tsconfig.json b/src/web/module-loader/tsconfig.json
new file mode 100644
index 0000000..5d6b09f
--- /dev/null
+++ b/src/web/module-loader/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../tsconfig.json",
+ "exclude": ["./dist"],
+ "include": ["./src"],
+ "compilerOptions": {
+ "declaration": true,
+ "outDir": "./dist",
+ "rootDir": "./src"
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index 00fcdd0..304c5bd 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1961,6 +1961,20 @@ __metadata:
languageName: unknown
linkType: soft
+"@sourceacademy/common-module-loader@workspace:*, @sourceacademy/common-module-loader@workspace:src/common/module-loader":
+ version: 0.0.0-use.local
+ resolution: "@sourceacademy/common-module-loader@workspace:src/common/module-loader"
+ dependencies:
+ "@rollup/plugin-node-resolve": "npm:^16.0.3"
+ "@rollup/plugin-terser": "npm:^1.0.0"
+ "@rollup/plugin-typescript": "npm:^12.3.0"
+ rollup: "npm:^4.60.2"
+ tslib: "npm:^2.8.1"
+ typescript: "npm:^6.0.3"
+ vitest: "npm:^4.1.9"
+ languageName: unknown
+ linkType: soft
+
"@sourceacademy/common-stepper@workspace:*, @sourceacademy/common-stepper@workspace:src/common/stepper":
version: 0.0.0-use.local
resolution: "@sourceacademy/common-stepper@workspace:src/common/stepper"
@@ -2050,6 +2064,26 @@ __metadata:
languageName: unknown
linkType: soft
+"@sourceacademy/runner-module-loader@workspace:src/runner/module-loader":
+ version: 0.0.0-use.local
+ resolution: "@sourceacademy/runner-module-loader@workspace:src/runner/module-loader"
+ dependencies:
+ "@rollup/plugin-node-resolve": "npm:^16.0.3"
+ "@rollup/plugin-terser": "npm:^1.0.0"
+ "@rollup/plugin-typescript": "npm:^12.3.0"
+ "@sourceacademy/common-module-loader": "workspace:*"
+ "@sourceacademy/conductor": "npm:>=0.3.0"
+ "@sourceacademy/runner-remote-execution": "workspace:*"
+ "@vitest/coverage-istanbul": "npm:^4.1.9"
+ rollup: "npm:^4.60.2"
+ tslib: "npm:^2.8.1"
+ typescript: "npm:^6.0.3"
+ vitest: "npm:^4.1.9"
+ peerDependencies:
+ "@sourceacademy/conductor": ">=0.3.0"
+ languageName: unknown
+ linkType: soft
+
"@sourceacademy/runner-remote-execution@workspace:*, @sourceacademy/runner-remote-execution@workspace:src/runner/remoteExecution":
version: 0.0.0-use.local
resolution: "@sourceacademy/runner-remote-execution@workspace:src/runner/remoteExecution"
@@ -2138,6 +2172,26 @@ __metadata:
languageName: unknown
linkType: soft
+"@sourceacademy/web-module-loader@workspace:src/web/module-loader":
+ version: 0.0.0-use.local
+ resolution: "@sourceacademy/web-module-loader@workspace:src/web/module-loader"
+ dependencies:
+ "@rollup/plugin-commonjs": "npm:^29.0.3"
+ "@rollup/plugin-node-resolve": "npm:^16.0.3"
+ "@rollup/plugin-terser": "npm:^1.0.0"
+ "@rollup/plugin-typescript": "npm:^12.3.0"
+ "@sourceacademy/common-module-loader": "workspace:*"
+ "@sourceacademy/common-tabs": "workspace:^"
+ "@sourceacademy/conductor": "npm:>=0.3.0"
+ rollup: "npm:^4.60.2"
+ tslib: "npm:^2.8.1"
+ typescript: "npm:^6.0.3"
+ vitest: "npm:^4.1.9"
+ peerDependencies:
+ "@sourceacademy/conductor": ">=0.3.0"
+ languageName: unknown
+ linkType: soft
+
"@sourceacademy/web-stepper@workspace:src/web/stepper":
version: 0.0.0-use.local
resolution: "@sourceacademy/web-stepper@workspace:src/web/stepper"