Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions __tests__/script-tags.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* @jest-environment node
*
* The pinned datapipe-client version in the pasted <script> tags has to follow
* the package. Changesets bumps packages/client/package.json in the release
* PR. The docs samples read that version directly, and
* packages/client/scripts/sync-readme-pin.mjs rewrites the README's pin in the
* same step. This suite catches either of those coming unstuck.
*/

import fs from "fs";
import path from "path";
import {
DATAPIPE_CLIENT_VERSION,
DATAPIPE_CLIENT_SCRIPT,
EXTENSION_PIPE_SCRIPT,
} from "../components/dashboard/script-tags";

const clientDir = path.join(__dirname, "..", "packages", "client");
const { version } = JSON.parse(
fs.readFileSync(path.join(clientDir, "package.json"), "utf8")
);

test("the docs pin the current datapipe-client version", () => {
expect(DATAPIPE_CLIENT_VERSION).toBe(version);
});

test("the client README pins the current version", () => {
const readme = fs.readFileSync(path.join(clientDir, "README.md"), "utf8");
const pins = [...readme.matchAll(/unpkg\.com\/datapipe-client(@[^/"]+)?/g)];
expect(pins.length).toBeGreaterThan(0);
for (const [, pin] of pins) expect(pin).toBe(`@${version}`);
});

test("both script tags carry an exact version", () => {
expect(DATAPIPE_CLIENT_SCRIPT).toMatch(/datapipe-client@\d+\.\d+\.\d+"/);
expect(EXTENSION_PIPE_SCRIPT).toMatch(/extension-pipe@\d+\.\d+\.\d+"/);
});
31 changes: 14 additions & 17 deletions components/dashboard/CodeHints.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ChevronDown } from "lucide-react";

import CodeBlock from "../CodeBlock";
import { extensionSnippet } from "./extension-snippet";
import { EXTENSION_PIPE_SCRIPT, DATAPIPE_CLIENT_SCRIPT } from "./script-tags";

export default function CodeHints({ expId }) {
const [language, setLanguage] = useState("jsPsych v8");
Expand Down Expand Up @@ -89,7 +90,7 @@ export default function CodeHints({ expId }) {
Load the extension and register it. That is the whole integration — there is no save trial to add.
</Text>
<CodeBlock language="html">
{`<script src="https://unpkg.com/@jspsych/extension-pipe"></script>`}
{EXTENSION_PIPE_SCRIPT}
</CodeBlock>
<CodeBlock>{extensionSnippet(expId)}</CodeBlock>
<Text fontSize="sm" color="fg.muted">
Expand All @@ -106,7 +107,7 @@ export default function CodeHints({ expId }) {
Use saveBase64Data to upload binary files (audio, video, images). This example saves audio from the html-audio-response plugin.
</Text>
<CodeBlock language="html">
{`<script src="https://unpkg.com/@jspsych/extension-pipe"></script>`}
{EXTENSION_PIPE_SCRIPT}
</CodeBlock>
<CodeBlock>
{`
Expand All @@ -132,7 +133,7 @@ export default function CodeHints({ expId }) {
Request the next condition assignment. This is async, so wrap your experiment in an async function.
</Text>
<CodeBlock language="html">
{`<script src="https://unpkg.com/@jspsych/extension-pipe"></script>`}
{EXTENSION_PIPE_SCRIPT}
</CodeBlock>
<CodeBlock>
{`
Expand Down Expand Up @@ -173,12 +174,12 @@ export default function CodeHints({ expId }) {
Send your data as a string with a unique filename.
</Text>
<CodeBlock language="html">
{`<script src="https://unpkg.com/datapipe-client"></script>`}
{DATAPIPE_CLIENT_SCRIPT}
</CodeBlock>
<CodeBlock>
{`
const result = await DataPipe.saveData({
experimentID: "${expId}",
experiment_id: "${expId}",
filename: "UNIQUE_FILENAME.csv",
data: dataAsString,
});
Expand All @@ -201,32 +202,28 @@ export default function CodeHints({ expId }) {
Send each trial as it happens, so a participant who closes the tab partway through does not take all of their data with them.
</Text>
<CodeBlock language="html">
{`<script src="https://unpkg.com/datapipe-client"></script>`}
{DATAPIPE_CLIENT_SCRIPT}
</CodeBlock>
<CodeBlock>
{`
const filename = "UNIQUE_FILENAME.csv";
const session = DataPipe.createSession({
experimentID: "${expId}",
experiment_id: "${expId}",
filename: filename,
});

// ...after each trial:
session.record(trialData);

// ...when the experiment ends:
await session.flush();
const result = await DataPipe.saveData({
experimentID: "${expId}",
experiment_id: "${expId}",
filename: filename,
data: dataAsString,
sessionId: session.sessionId,
session: session,
});
await session.close({ submitted: result.ok });`}
</CodeBlock>
<Text fontSize="sm" color="fg.muted">
Flush before reading sessionId: the session starts in the background, and until it has, the id is empty. Submitting without it leaves the staged copy unmatched, and it comes back as a duplicate .partial.json.
</Text>
<Text fontSize="sm" color="fg.muted">
A participant who finishes produces one ordinary file. One who quits partway produces a separate file ending in .partial.json, holding the trials they completed. Partial sessions do not count toward your session limit.
</Text>
Expand All @@ -238,12 +235,12 @@ export default function CodeHints({ expId }) {
Send binary data (audio, video, images) as a base64 string. DataPipe decodes it and uploads the file to your storage provider.
</Text>
<CodeBlock language="html">
{`<script src="https://unpkg.com/datapipe-client"></script>`}
{DATAPIPE_CLIENT_SCRIPT}
</CodeBlock>
<CodeBlock>
{`
const result = await DataPipe.saveBase64Data({
experimentID: "${expId}",
experiment_id: "${expId}",
filename: "UNIQUE_FILENAME.webm",
data: base64DataString,
});`}
Expand All @@ -256,13 +253,13 @@ export default function CodeHints({ expId }) {
Request the next condition assignment, a number starting at 0.
</Text>
<CodeBlock language="html">
{`<script src="https://unpkg.com/datapipe-client"></script>`}
{DATAPIPE_CLIENT_SCRIPT}
</CodeBlock>
<CodeBlock>
{`
let condition;
try {
condition = await DataPipe.getCondition({ experimentID: "${expId}" });
condition = await DataPipe.getCondition({ experiment_id: "${expId}" });
} catch (error) {
document.body.innerHTML = "<p>The experiment could not be started.</p>";
throw error;
Expand Down
22 changes: 22 additions & 0 deletions components/dashboard/script-tags.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// The <script> tags every code sample tells a researcher to paste.
//
// Pinned to exact versions on purpose. An unpinned unpkg URL resolves to
// whatever was published most recently, so a release could change a study
// that is already collecting data, mid-study and without anyone touching it.
// A pinned URL keeps a running study on the code it was piloted with.
//
// The client's version is read from its package.json rather than written
// here, so the "Release datapipe-client" PR moves the pin by bumping the
// version, with no step to forget. It can't be synced by a script instead:
// changesets/action commits only files under packages/client, so an edit to
// this file made during that PR's version step would never be committed.
//
// The extension lives in the jsPsych repo, so bump its version by hand when a
// new one is published.
import clientPackage from "../../packages/client/package.json";

export const EXTENSION_PIPE_VERSION = "0.2.0";
export const DATAPIPE_CLIENT_VERSION = clientPackage.version;

export const EXTENSION_PIPE_SCRIPT = `<script src="https://unpkg.com/@jspsych/extension-pipe@${EXTENSION_PIPE_VERSION}"></script>`;
export const DATAPIPE_CLIENT_SCRIPT = `<script src="https://unpkg.com/datapipe-client@${DATAPIPE_CLIENT_VERSION}"></script>`;
2 changes: 1 addition & 1 deletion components/home/hero-snippets.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const snippets = [
{ role: "fg", text: "await DataPipe." },
{ role: "fn", text: "saveData" },
{ role: "fg", text: "({\n" },
{ role: "fg", text: " experimentID: " },
{ role: "fg", text: " experiment_id: " },
{ role: "string", text: '"your_id"' },
{ role: "fg", text: ",\n" },
{ role: "fg", text: " filename: " },
Expand Down
7 changes: 7 additions & 0 deletions packages/client/.changeset/experiment-id-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"datapipe-client": minor
---

Accept `experiment_id`, the name the jsPsych extension and plugin use for the experiment ID, so the same option is spelled the same way everywhere.

`experimentID` still works and there are no plans to remove it. Give one or the other. If both are given and differ, `saveData`, `saveBase64Data` and `getCondition` throw, and a session starts inert with a console warning. Nothing changes on the wire.
7 changes: 7 additions & 0 deletions packages/client/.changeset/session-ready.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"datapipe-client": minor
---

Pass the session to `saveData` with `session` instead of flushing and reading `sessionId` yourself. `saveData` waits for the session to start and sends its id.

Adds `session.ready()`, which resolves once the session has started (or failed to). It waits only for startup, not for staged writes, so a final submission from a background tab is no longer held up by a throttled flush. `sessionId` still works.
21 changes: 11 additions & 10 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,18 @@ npm install datapipe-client
Or in a plain HTML page, which exposes a `DataPipe` global:

```html
<script src="https://unpkg.com/datapipe-client/dist/datapipe-client.browser.global.js"></script>
<script src="https://unpkg.com/datapipe-client@0.1.0/dist/datapipe-client.browser.global.js"></script>
```

Keep the version in the URL. Without one, unpkg serves the newest release, which could change a study that is already collecting data.

## Sending data at the end

```js
import { saveData } from "datapipe-client";

const result = await saveData({
experimentID: "YOUR_EXPERIMENT_ID",
experiment_id: "YOUR_EXPERIMENT_ID",
filename: "subject-01.csv",
data: "rt,response\n204,1\n389,0",
});
Expand All @@ -42,28 +44,27 @@ Staging each trial as it is produced means a participant who closes the tab at t
import { createSession, saveData } from "datapipe-client";

const session = createSession({
experimentID: "YOUR_EXPERIMENT_ID",
experiment_id: "YOUR_EXPERIMENT_ID",
filename: "subject-01.csv",
});

// ...after each trial:
session.record(trialData);

// ...at the end:
await session.flush();
const result = await saveData({
experimentID: "YOUR_EXPERIMENT_ID",
experiment_id: "YOUR_EXPERIMENT_ID",
filename: "subject-01.csv",
data: allTrialsAsCSV,
sessionId: session.sessionId,
session,
});
await session.close({ submitted: result.ok });
```

Three things are worth knowing:

- **`createSession()` returns immediately.** The request that starts the session is still in flight, and trials recorded before it lands are buffered and staged once it does. Use `await startSession(...)` instead if you would rather wait and check `session.enabled`.
- **Flush before reading `sessionId`.** `flush()` waits for the session to start, so until you have awaited it, `sessionId` may still be empty. Submitting without it leaves DataPipe unable to match your file to the staged copy, which it would then recover a second time.
- **Pass the session to `saveData`.** It tells DataPipe that this submission completes the staged copy, so the staged copy is discarded instead of being recovered as a second file. If you build the request yourself, `await session.ready()` and send `session.sessionId`.
- **Tell `close()` what happened.** `{ submitted: true }` cancels the abandonment marker, so a completed session is never also reported as abandoned. `{ submitted: false }` marks it now, so the staged trials are recovered on DataPipe's normal sweep rather than waiting out the 24-hour expiry.

Nothing about staging will break your experiment. If the session cannot be started, every method on it becomes a no-op and the data is still submitted at the end.
Expand All @@ -75,7 +76,7 @@ import { getCondition } from "datapipe-client";

let condition;
try {
condition = await getCondition({ experimentID: "YOUR_EXPERIMENT_ID" });
condition = await getCondition({ experiment_id: "YOUR_EXPERIMENT_ID" });
} catch (error) {
document.body.innerHTML = "<p>The experiment could not be started.</p>";
throw error;
Expand All @@ -90,7 +91,7 @@ try {
import { saveBase64Data } from "datapipe-client";

await saveBase64Data({
experimentID: "YOUR_EXPERIMENT_ID",
experiment_id: "YOUR_EXPERIMENT_ID",
filename: "subject-01-recording.webm",
data: base64EncodedString,
});
Expand All @@ -114,7 +115,7 @@ setBaseURL("https://datapipe-test.web.app");
| `getCondition(options)` | `Promise<number>` | **yes** |
| `setBaseURL(url)` / `getBaseURL()` | — | no |

`DataPipeSession` has `enabled`, `sessionId`, `record(data)`, `flush()`, and `close({ submitted })`.
`DataPipeSession` has `enabled`, `sessionId`, `ready()`, `record(data)`, `flush()`, and `close({ submitted })`.

`SaveResult` is `{ ok: boolean, status: number, body: any }`. A `status` of `0` means the request never reached DataPipe.

Expand Down
2 changes: 1 addition & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"test:watch": "vitest",
"size": "node scripts/report-size.mjs",
"changeset": "changeset",
"version-packages": "changeset version",
"version-packages": "changeset version && node scripts/sync-readme-pin.mjs",
"release": "npm run build && changeset publish"
},
"dependencies": {
Expand Down
33 changes: 33 additions & 0 deletions packages/client/scripts/sync-readme-pin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Points the README's unpkg URLs at the version in package.json.
//
// Runs as part of `npm run version-packages`, after `changeset version` has
// bumped package.json, so the "Release datapipe-client" PR carries the new
// pin and npm publishes a README whose script tag loads the release it
// ships with. The DataPipe site needs no counterpart: its samples read the
// version from package.json directly (components/dashboard/script-tags.js).
//
// Fails, rather than doing nothing, if the README has no unpkg URL to pin.
// A silent no-op would ship a README pointing at the previous release.

import { readFileSync, writeFileSync } from "node:fs";

const packageURL = new URL("../package.json", import.meta.url);
const readmeURL = new URL("../README.md", import.meta.url);

const { version } = JSON.parse(readFileSync(packageURL, "utf8"));
const readme = readFileSync(readmeURL, "utf8");

// Matches the URL with or without a version, so an unpinned one gets pinned.
const pin = /unpkg\.com\/datapipe-client(@[^/"\s]+)?/g;
if (!pin.test(readme)) {
console.error("sync-readme-pin: README.md has no unpkg.com/datapipe-client URL to pin.");
process.exit(1);
}

const updated = readme.replace(pin, `unpkg.com/datapipe-client@${version}`);
if (updated !== readme) {
writeFileSync(readmeURL, updated);
console.log(`sync-readme-pin: README.md now pins datapipe-client@${version}.`);
} else {
console.log(`sync-readme-pin: README.md already pins datapipe-client@${version}.`);
}
Loading
Loading