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:
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:
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:
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.