Skip to content

Creating a Bundle

Lee Yi edited this page Apr 16, 2023 · 10 revisions

This page contains a guide for Source Module authors to create their very own Source Module Bundle as well as details and explanations on the structure of a Source Module Bundle.

Contents

Introduction

Similar to regular Javascript modules, Source allows developers to export functions and constants to users for importing into their programs.

For example, the binary_tree module may want to provide an abstraction for Source programs to interact with the Binary Tree data structure. Thus, the binary_tree module would expose functions such as make_tree, left_branch and right_branch to be used in Source programs.

The typical bundle structure for a bundle looks like this:

.
└── src/
    ├── binary_tree/
    │   └── index.ts  // Entry Point
    └── curve/
        ├── index.ts
        ├── functions.ts
        └── ... // regular Javascript module code

Only functions that are exported by the module will be made available to users. Let us look at an example from the curve module.

// curve/functions.ts
/**
 * Makes a Point with given x and y coordinates.
 *
 * @param x x-coordinate of new point
 * @param y y-coordinate of new point
 * @returns with x and y as coordinates
 * @example
 * ```
 * const point = make_point(0.5, 0.5);
 * ```
 */
export function make_point(x: number, y: number): Point {
  return new Point(x, y, 0, [0, 0, 0, 1]);
}

/**
 * Use this function to create the various `draw_connected` functions
 */ 
export function createDrawFunction(
  scaleMode: ScaleMode,
  drawMode: DrawMode,
  space: CurveSpace,
  isFullView: boolean,
): (numPoints: number) => RenderFunction {
  // implementation hidden...
}

Note that curve/functions.ts exports both createDrawFunction and make_point.

// curve/index.ts
export { make_point } from './functions';

Only make_point is exported at the bundle's entry point however createDrawFunction is not, so users will not be able to access it, identical to how ES modules behave.

An aside about default exports

NOTE: This section is only applicable if you are still using the Rollup + Babel build system for modules. If you are using the ESBuild system default exports are entirely unsupported.

// curve/index.ts
export { make_point } from './functions'

and

// curve/index.ts
import { make_point } from './functions'
export default {
  make_point,
}

are functionally identical: both expose make_point to the user. However, combining both regular exports and a default export will cause the default export to be hidden.

import { make_point } from './functions'
export default {
  make_point,
}

export { r_of, g_of, b_of } from './functions'; 
// Source Program
import { b_of, make_point } from 'curve'; // will result in b_of being defined but make_point will be undefined

Currently Source does not support namespace or default imports, so the make_point function will not be able to be imported into Source programs.

Module Contexts

Some times, a bundle needs to be able to maintain some state information, or send information to a tab. Module Contexts form the solution to this problem.

Every time js-slang evaluates Source code, it creates an evaluation context. Bundles can access this context by using this import:

// curve/functions.ts
import context from 'js-slang/context';
const drawnCurves = [];
context.moduleContexts.curve.state = {
  drawnCurves,
}

context.moduleContexts will not be null here, and is of the type Record<string, { tabs: any[], state: any }>. To access a module's context, simply index the moduleContexts object using the bundle's name.

The state object can be of any type - it is up to the developer to decide what needs to be stored as state.

This state object can then be accessed by the module's tab, for example:

// Curve/index.tsx
export default {
  toSpawn: (context) => {
    return context.context.moduleHelpers.curve.state.drawnCurves.length > 0;
  },
  body: (context) => { /* implementation */ },
}

For more information refer to the documentation for tabs.

js-slang guarantees that each module is only evaluated once per code evaluation, no matter how many import statements there are in a Source program.

Where to access context?

Consider the following situation:

// curve/functions_0.ts
import context from 'js-slang/context';
const drawnCurves = [];
context.moduleContexts.curve.state = {
  drawnCurves,
}

export const draw_connected = (...) => {...}

// curve/functions_1.ts
import context from 'js-slang/context';
const drawnCurves = [];
context.moduleContexts.curve.state = {
  drawnCurves,
}
export const draw_3d_connected = (...) => {...}

// curve/functions_2.ts
import { draw_connected } from './functions_0.ts';
import context from 'js-slang/context';
const drawnCurves = [];
context.moduleContexts.curve.state = {
  drawnCurves,
}

export const someOtherFunc = (...) => { ... }

// curve/index.ts
export { draw_connected } from './functions_0.ts';
export { draw_3d_connected } from './functions_1.ts';
export { someOtherFunc } from './functions_2.ts';

Which state setting code would be evaluated first?

Code that does not imports code from other files will be evaluated first, in this case functions_2.ts (because functions_1.ts relies on it). However, it is not clear which of functions_0.ts or functions_1.ts will be evaluated first. Thus, importing the context multiple times will cause both writes and reads to that object to exhibit undefined behaviour.

To remedy this, either only import the context once in your bundle (recommended), and then have it exported for the rest of the bundle's code to use, or add checks:

import context from 'js-slang/context';

let drawnCurves = [];
if (context.moduleHelpers.curve.state) {
  drawnCurves = context.moduleContexts.curve.state.drawnCurves;
} else {
  context.moduleHelpers.curve.state = {
    drawnCurves,
  }
}

Importing the context only in index.ts (which is guaranteed to be evaluated last - it needs the rest of the code from your bundle) could also work, but will probably result in circular dependency warnings.

Accessing Other Modules

It is possible for one bundle to access the context of another:

import context from 'js-slang/context';

// If the rune module was also loaded, this object *may* not be null
if (context.moduleContexts.rune) {
  console.log('Both the rune and curve modules were loaded!')
} else {
  console.log('Only the curve module was loaded')
}

However, the order in which modules are evaluated is not guaranteed. In the above code, if the curve module was evaluated first, it would indicate that only the curve module was loaded since rune's state object has yet to be initialized. Thus, use this feature with caution.

Initializing Context Outside the bundle

Normally data flows from the bundle to the context object: i.e. the bundle contains the code that initializes the module's state object. However, it is also possible for the module's state object to be initialized before the bundle is loaded.

For example, the game module's room preview feature utilizes a special evaluation context:

// within createContext()

// Create an evaluation context
this.context = createContext(Chapter.SOURCE_4, [], 'playground', Variant.DEFAULT);

// Initialize the context for the game module
this.context.moduleContexts.game = {
  tabs: null,
  state: {
    scene: this,
    preloadImageMap: this.preloadImageMap,
    preloadSoundMap: this.preloadSoundMap,
    preloadSpritesheetMap: this.preloadSpritesheetMap,
    remotePath: (file: string) => toS3Path(file, true),
    screenSize: screenSize,
    createAward: (x: number, y: number, key: ItemId) => this.createAward(x, y, key)
  }
};

// Pass the context to the runInContext function from js-slang

The game bundle is then able to use the data provided to it:

// game/functions.ts
import context from 'js-slang/moduleContexts';

export default function gameFuncs() {
  const {
    scene,
    preloadImageMap,
    preloadSoundMap,
    preloadSpritesheetMap,
    remotePath,
    screenSize,
    createAward,
  } = context.moduleContexts.game.state || {
    // ...defaultValues
  };
  // Here we know for sure that the game module's state object has been initialized
  // but the check is still here just in case the module was not used in its intended way


  // do other things...
}
Clone this wiki locally