Skip to content

Instruments

What is an Instrument?

Central to Open Data Capture is the concept of an instrument. Conceptually, an instrument is any tool that can be used to collect data. This can range from simple forms (e.g., a questionnaire assessing depressive symptoms) to complex interactive tasks (e.g., the Stroop Task).

Technically, an Instrument is a JavaScript Object, which is composed of a key-value pairs, similar to a dictionary in Python. These objects define the properties of an instrument (e.g., a title and description).

Understanding TypeScript

The structure of an Instrument is defined in TypeScript by the Instrument type, located in the internal schemas package. This definition is essentially a set of rules to validate that the key-value pairs in an Instrument conform with expectations in the codebase. These rules are quite complex and heavily rely on the concept of discriminated unions and conditional types. However, it is important to understand that TypeScript types do not exist at runtime; rather, they are used to provide compile-time checks (e.g., when developing a new Instrument). Ultimately, at runtime, an Instrument is simply a JavaScript object.

Instrument Structure

Instrument Types

The Instrument type is a union of several members assignable to BaseInstrument, such as FormInstrument and InteractiveInstrument. Each member of the union has a distinct kind property which serves to discriminate its specific type.

For instance, consider an object T of type Instrument. Initially, we can only know that T is assignable to BaseInstrument. However, by examining T["kind"], we can narrow the type of instrument: if T["kind"] equals "FORM", then T is a FormInstrument; if it equals "INTERACTIVE", then T is an InteractiveInstrument.

Now, consider the content property of an Instrument which defines the content in the instrument to be rendered to the user. For a FormInstrument, content contains the definition of the form to display to the user. On the other hand, for an InteractiveInstrument, then content contains a render function that will be invoked in an iframe.

Instrument Sources

Theoretically, an instrument could be defined in the codebase as a simple JavaScript object. For example, the following is an example of a simple form instrument:

const formInstrument = {
kind: 'FORM',
language: 'en',
tags: ['Example'],
internal: {
edition: 1,
name: 'HAPPINESS_QUESTIONNAIRE'
},
content: {
overallHappiness: {
description: 'Please select a number from 1 to 10 (inclusive)',
kind: 'number',
label: 'How happy are you overall?',
max: 10,
min: 1,
variant: 'slider'
}
},
details: {
description: 'The Happiness Questionnaire is a questionnaire about happiness.',
estimatedDuration: 1,
instructions: ['Please answer the questions based on your current feelings.'],
license: 'Apache-2.0',
title: 'Happiness Questionnaire'
},
measures: null,
validationSchema: z.object({
overallHappiness: z.number().int().min(1).max(10)
})
};

However, in real-world situations this strategy is quite limiting for interactive instruments. Often, an interactive instrument includes a number of other files, such as CSS, HTML, JavaScript, as well as other assets such as fonts and images. In addition, if instruments could only be defined in the code base, adding even one additional instrument would require a total rebuild of at least some of the application. For these reasons, instruments are generated from instrument sources; instrument sources are the files that define an instrument.

For a very simple instrument, such as the example above, the instruments sources may be a single file. This file would then be considered the index file. The index file is the entry point to the instrument. This file must have as its default export a valid instrument.

Instrument Bundler

Although we have thusfar discussed files, the instrument bundler is platform-agnostic, and does not rely on the file system. Instead, it accepts an array of inputs; an input is an object containing a name and source (like a virtual file). These inputs are then bundled into a single output. This is done using esbuild with a custom plugin.

To resolve the index file, the bundler checks for the following names (in order):

  • index.tsx
  • index.jsx
  • index.ts
  • index.js

Once found, this index file is injected into a string which is passed as the input to esbuild. For example, if the index file is index.js, then it is passed the following string:

import instrument from './index.js'; var __exports = instrument;

Our custom plugin assumes responsibility for resolving all imports found. In this case, we resolve the static relative import './index.js which exists in the inputs. We then return the content of the index input to esbuild for further processing. In this case, esbuild will look for imports in the content of the index, and pass any results to our resolver. The resolver marks any HTTP imports as external (e.g., import React from ‘/runtime/v1/react@18.x’). It looks for any static imports inside the inputs and throws an exception if it is not found. If it is found, the content is passed to esbuild and the bundling process continues.

Ultimately, this results in one JavaScript virtual file being generated, and one CSS virtual file being generated (if CSS is imported). If applicable, the CSS file is a bundle of all stylesheets used by the instrument.

Since we know that the JavaScript file contains a variable __exports which is the default export of the instrument, the CSS is converted to base64 and inserted into the JavaScript bundle as follows:

// lots of stuff generated by esbuild
var __exports = index_default;
__exports.content.__injectHead.style = '...'; // base64 encoded string

Then, when rendering interactive instruments, a script looks for the special content.__injectHead property and decodes the styles if found. This is inserted into the document head before the render method is called.

This new content is then passed into the esbuild transpiler as a single asynchronous Immediately Invoked Function Expression that resolves to a Promise of an Instrument. This output conforms to the ECMAScript 2022 Language Specification and can be executed in any modern browser. At this point, the code can be minified and tree shaken (i.e., dead code removed).

For example:

`(async () => {
// code with styles injected (if applicable)
return __exports;
} )()`;

Storage

This bundle is then stored in the database. It can be evaluated in the global scope (using indirect eval or the Function constructor) at runtime.