diff --git a/.cursor/rules/architecture.mdc b/.cursor/rules/architecture.mdc index 8d33ab0..e212715 100644 --- a/.cursor/rules/architecture.mdc +++ b/.cursor/rules/architecture.mdc @@ -1,12 +1,11 @@ --- -description: General structure of the app -globs: +alwaysApply: true --- - # Structure - Read, but don't edit @README.md to get an idea of how the app should work. - Where possible, the app should be separated into an API and frontend components. - Tests should be written as the app is developed. - Use package.json to ensure you're using the correct packages to implement features and not installing redundant packages or reinventing the wheel. -- User superforms for all forms \ No newline at end of file +- User superforms for all forms +- For now, we will keep multiplayer and single player experiment serving, queuing, and session logic completely separate, even if it means overlapping and redundant code. We will optimise later. \ No newline at end of file diff --git a/.cursor/rules/multiplayer.mdc b/.cursor/rules/multiplayer.mdc new file mode 100644 index 0000000..9c7547e --- /dev/null +++ b/.cursor/rules/multiplayer.mdc @@ -0,0 +1,8 @@ +--- +description: Multiplayer experiment implementation +globs: +alwaysApply: false +--- + +- Multiplayer experiments should be 'run' by the app, not on the participant's computers like singleplayer experiments. This enables participants to drop connection and rejoin the session where they left off +- All participants should see the same thing at the same time, but *later* I want to give the experimenter the ability to occasionally show participants different things based on a URL variable that this app will inject based on some . So participants will be on the same 'screen' but with different images, for example. The ability for experimenters to show different things should not be implemented unless directly requested, but any solutions to the basic multiplayer implementation consider that this needs to be possible in the future. \ No newline at end of file diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index c58cdcf..e5abd86 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -29,17 +29,22 @@ export const experiment = pgTable('experiment', { type: experimentTypeEnum('type').notNull() }); -export const participantSession = pgTable('participant_session', { +export const sessionStatusEnum = pgEnum('session_status', ['in progress', 'completed', 'aborted']); + +export const experimentSession = pgTable('experiment_session', { id: text('id').primaryKey(), experimentId: text('experiment_id') .notNull() .references(() => experiment.id, { onDelete: 'cascade' }), createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).notNull(), + status: sessionStatusEnum('status').notNull(), + externalId: text('external_id'), ipAddress: text('ip_address'), userAgent: text('user_agent') }); export type Session = typeof session.$inferSelect; export type User = typeof user.$inferSelect; -export type ParticipantSession = typeof participantSession.$inferSelect; export type Experiment = typeof experiment.$inferSelect; +export type ExperimentSession = typeof experimentSession.$inferSelect; diff --git a/src/routes/api/experiment/[id]/sessions/+server.ts b/src/routes/api/experiment/[id]/sessions/+server.ts new file mode 100644 index 0000000..5a765ad --- /dev/null +++ b/src/routes/api/experiment/[id]/sessions/+server.ts @@ -0,0 +1,40 @@ +import { db } from '$lib/server/db'; +import { experimentSession } from '$lib/server/db/schema'; +import { json } from '@sveltejs/kit'; +import { and, eq, desc, sql } from 'drizzle-orm'; +import { z } from 'zod'; + +const searchParamsSchema = z.object({ + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().default(10) +}); + +export async function GET({ params, url }) { + const { id } = params; + const searchParams = searchParamsSchema.parse(Object.fromEntries(url.searchParams)); + + try { + const sessions = await db + .select() + .from(experimentSession) + .where(eq(experimentSession.experimentId, id)) + .orderBy(desc(experimentSession.createdAt)) + .limit(searchParams.limit) + .offset((searchParams.page - 1) * searchParams.limit); + + const total = await db + .select({ count: sql`count(*)` }) + .from(experimentSession) + .where(eq(experimentSession.experimentId, id)); + + return json({ + sessions, + total: total[0].count, + page: searchParams.page, + limit: searchParams.limit + }); + } catch (e) { + console.error(e); + return json({ error: 'Something went wrong' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/routes/experiment/[id]/+page.svelte b/src/routes/experiment/[id]/+page.svelte index 71e5eb5..9607b99 100644 --- a/src/routes/experiment/[id]/+page.svelte +++ b/src/routes/experiment/[id]/+page.svelte @@ -7,7 +7,8 @@ import { Label } from '$lib/components/ui/label'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { goto } from '$app/navigation'; import { get } from 'svelte/store'; -import FileBrowser from './FileBrowser.svelte'; +import FileManager from './FileManager.svelte'; +import ExperimentSessions from './ExperimentSessions.svelte'; let experimentId = ''; let experiment: any = null; @@ -15,192 +16,49 @@ let name = ''; let description = ''; let error = ''; let success = ''; -let files: any[] = []; -let uploading = false; -let uploadError = ''; let copied = false; let origin = ''; let activeTab = 'info'; -async function fetchFiles() { - if (!experimentId) return; - console.log('fetchFiles called for experimentId:', experimentId); - const res = await fetch(`/api/experiment/${experimentId}/files`); - if (res.ok) { - const data = await res.json(); - console.log('fetchFiles response:', data); - files = data.files; - } else { - console.log('fetchFiles failed:', res.status); - } -} - -async function handleFileInput(event: Event) { - const input = event.target as HTMLInputElement; - if (input.files) await uploadFiles(input.files); -} - -async function uploadFiles(fileList: FileList) { - uploading = true; - uploadError = ''; - try { - for (const file of Array.from(fileList)) { - const form = new FormData(); - form.append('file', file); - // Use webkitRelativePath if present, else fallback to file.name - form.append('relativePath', file.webkitRelativePath || file.name); - const res = await fetch(`/api/experiment/${experimentId}/files`, { - method: 'POST', - body: form, - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || 'Upload failed'); - } - } - await fetchFiles(); - } catch (e: any) { - uploadError = e.message || 'Upload failed'; - } finally { - uploading = false; - } -} - -async function deleteFile(key: string) { - // If key contains experiment_files/, strip everything before and including it - let relativeKey = key; - const expFilesIdx = key.indexOf('/experiment_files/'); - if (expFilesIdx !== -1) { - relativeKey = key.substring(expFilesIdx + '/experiment_files/'.length); - } else { - // fallback: if key contains experimentId, strip up to and including it - const expIdIdx = key.indexOf(experimentId + '/'); - if (expIdIdx !== -1) { - relativeKey = key.substring(expIdIdx + experimentId.length + 1); - } - } - const url = `/api/experiment/${experimentId}/files?key=${encodeURIComponent(relativeKey)}`; - const res = await fetch(url, { method: 'DELETE' }); - if (res.ok) await fetchFiles(); -} - -// Helper: Convert flat file list to tree -function buildFileTree(files: any[]): any { - const root: any = {}; - for (const file of files) { - const parts = file.name.split('/'); - let node = root; - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - if (!part) continue; - if (i === parts.length - 1) { - // File - node[part] = { ...file, isFile: true }; - } else { - // Folder - node[part] = node[part] || { children: {}, isFile: false }; - node = node[part].children; - } - } - } - return root; -} - -function downloadFile(key: string) { - // Download file from S3 via API - const url = `/api/experiment/${experimentId}/files?key=${encodeURIComponent(key)}&download=1`; - fetch(url) - .then(res => { - if (!res.ok) throw new Error('Failed to download'); - const disposition = res.headers.get('Content-Disposition'); - let filename = key.split('/').pop() || 'file'; - if (disposition) { - const match = disposition.match(/filename="?([^";]+)"?/); - if (match) filename = match[1]; - } - return res.blob().then(blob => ({ blob, filename })); - }) - .then(({ blob, filename }) => { - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - setTimeout(() => { - window.URL.revokeObjectURL(url); - a.remove(); - }, 100); - }) - .catch(() => alert('Failed to download file.')); -} - -let expanded = new Set(); -function toggleFolder(path: string) { - if (expanded.has(path)) expanded.delete(path); - else expanded.add(path); - expanded = new Set(expanded); // trigger reactivity -} +$: publicLink = experiment + ? `${origin}/public/${experiment.multiplayer ? 'multiplayer/run' : 'run'}/${experiment.id}` + : ''; function copyLink() { - const link = `${origin}/public/run/${experiment.id}`; - navigator.clipboard.writeText(link); - copied = true; - setTimeout(() => (copied = false), 2000); -} - -async function deleteFileOrFolder(key: string, isFolder: boolean) { - console.log('deleteFileOrFolder called:', { key, isFolder, experimentId }); - if (isFolder) { - // For folders, use the same logic to get the relative prefix - let relativePrefix = key; - const expFilesIdx = key.indexOf('/experiment_files/'); - if (expFilesIdx !== -1) { - relativePrefix = key.substring(expFilesIdx + '/experiment_files/'.length); - } else { - const expIdIdx = key.indexOf(experimentId + '/'); - if (expIdIdx !== -1) { - relativePrefix = key.substring(expIdIdx + experimentId.length + 1); - } - } - const url = `/api/experiment/${experimentId}/files?prefix=${encodeURIComponent(relativePrefix)}`; - const res = await fetch(url, { method: 'DELETE' }); - if (res.ok) await fetchFiles(); - } else { - await deleteFile(key); - } + navigator.clipboard.writeText(publicLink); + copied = true; + setTimeout(() => (copied = false), 2000); } onMount(async () => { - origin = window.location.origin; - const params = get(page).params; - experimentId = params.id; - const res = await fetch(`/api/experiment?id=${experimentId}`); - if (res.ok) { - const data = await res.json(); - experiment = data.experiment; - console.log(experiment) - name = experiment.name; - description = experiment.description; - } else { - error = 'Failed to load experiment.'; - } - await fetchFiles(); + origin = window.location.origin; + const params = get(page).params; + experimentId = params.id; + const res = await fetch(`/api/experiment?id=${experimentId}`); + if (res.ok) { + const data = await res.json(); + experiment = data.experiment; + console.log(experiment) + name = experiment.name; + description = experiment.description; + } else { + error = 'Failed to load experiment.'; + } }); async function handleSave() { - error = ''; - success = ''; - const res = await fetch(`/api/experiment?id=${experimentId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, description }) - }); - if (res.ok) { - success = 'Experiment updated!'; - } else { - error = 'Failed to update experiment.'; - } + error = ''; + success = ''; + const res = await fetch(`/api/experiment?id=${experimentId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description }) + }); + if (res.ok) { + success = 'Experiment updated!'; + } else { + error = 'Failed to update experiment.'; + } } @@ -210,6 +68,7 @@ async function handleSave() {
+
{#if activeTab === 'info'} @@ -253,7 +112,7 @@ async function handleSave() {
- +
{#if copied} @@ -262,47 +121,10 @@ async function handleSave() {
- {/if} - {#if activeTab === 'files'} - - - Experiment Files - - -
-
- - -
- {#if uploading} -
Uploading...
- {/if} - {#if uploadError} -
{uploadError}
- {/if} -
    - - {#if files.length === 0} -
  • No files uploaded yet.
  • - {/if} -
-
-
-
+ {:else if activeTab === 'files'} + + {:else if activeTab === 'sessions'} + {/if} diff --git a/src/routes/experiment/[id]/ExperimentSessions.svelte b/src/routes/experiment/[id]/ExperimentSessions.svelte new file mode 100644 index 0000000..5bb7b85 --- /dev/null +++ b/src/routes/experiment/[id]/ExperimentSessions.svelte @@ -0,0 +1,92 @@ + + +
+ + + + Session ID + Status + Created At + Updated At + External ID + + + + {#if loading} + + Loading... + + {:else if sessions.length === 0} + + No sessions found. + + {:else} + {#each sessions as session} + + {session.id} + {session.status} + {new Date(session.createdAt).toLocaleString()} + {new Date(session.updatedAt).toLocaleString()} + {session.externalId || 'N/A'} + + {/each} + {/if} + +
+
+
+

+ Showing {Math.min((page - 1) * limit + 1, total)} to {Math.min(page * limit, total)} of {total} sessions +

+
+
+ + +
+
+
\ No newline at end of file diff --git a/src/routes/experiment/[id]/FileManager.svelte b/src/routes/experiment/[id]/FileManager.svelte new file mode 100644 index 0000000..8489b94 --- /dev/null +++ b/src/routes/experiment/[id]/FileManager.svelte @@ -0,0 +1,193 @@ + + + + + Experiment Files + + +
+
+ + +
+ {#if uploading} +
Uploading...
+ {/if} + {#if uploadError} +
{uploadError}
+ {/if} +
    + + {#if files.length === 0} +
  • No files uploaded yet.
  • + {/if} +
+
+
+
\ No newline at end of file diff --git a/src/routes/public/multiplayer/run/[experimentId]/[...path]/+server.ts b/src/routes/public/multiplayer/run/[experimentId]/[...path]/+server.ts new file mode 100644 index 0000000..a2b83f7 --- /dev/null +++ b/src/routes/public/multiplayer/run/[experimentId]/[...path]/+server.ts @@ -0,0 +1,274 @@ +import { db } from '$lib/server/db/index.js'; +import * as schema from '$lib/server/db/schema.js'; +import s3 from '$lib/server/s3.js'; +import { GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'; +import { randomUUID } from 'crypto'; +import { eq } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import mime from 'mime-types'; +import { S3_BUCKET } from '$env/static/private'; +import { dev } from '$app/environment'; +import { promises as fs } from 'fs'; + +const EXPERIMENT_SESSION_COOKIE_PREFIX = 'experiment-session-'; + +// Rules for proxying vendor files to a CDN using regex to extract versions +const vendorFileRules = [ + { + regex: /^lib\/vendors\/jquery-([\d.]+)\.min\.js$/, + url: (version: string) => `https://code.jquery.com/jquery-${version}.min.js` + }, + { + regex: /^lib\/vendors\/surveyjs\.jquery-([\d.]+)\.min\.js$/, + url: (version: string) => `https://unpkg.com/survey-jquery@${version}/survey.jquery.min.js` + }, + { + regex: /^lib\/vendors\/surveyjs\.defaultV2-([\d.]+)-OST\.min\.css$/, + url: (version: string) => `https://unpkg.com/survey-core@${version}/defaultV2.min.css` + } +]; + +export async function GET({ params, cookies, getClientAddress, request }) { + const { experimentId, path } = params; + + // Check if the experiment is a multiplayer experiment + const experiment = await db.query.experiment.findFirst({ + where: eq(schema.experiment.id, experimentId) + }); + + if (!experiment) { + throw error(404, 'Experiment not found'); + } + + if (!experiment.multiplayer) { + throw error(403, 'This is not a multiplayer experiment'); + } + + + // Map of requested CSS files to their location in the static directory. + const staticCssMap: Record = { + 'lib/vendors/survey.widgets.css': 'static/lib/psychoJS/surveyJS/survey.widgets.css', + 'lib/vendors/survey.grey_style.css': 'static/lib/psychoJS/surveyJS/survey.grey_style.css' + }; + + if (path in staticCssMap) { + const filePath = staticCssMap[path]; + try { + const fileContents = await fs.readFile(filePath, 'utf-8'); + return new Response(fileContents, { + headers: { 'Content-Type': 'text/css' } + }); + } catch (e: any) { + if (e.code === 'ENOENT') { + throw error(404, 'File not found in static directory'); + } + throw error(500, 'Error reading static file'); + } + } + + // Check if the requested path is a vendor file and proxy to CDN + for (const rule of vendorFileRules) { + const match = path.match(rule.regex); + if (match) { + try { + // The first element of match is the full string, subsequent elements are capture groups. + const cdnUrl = (rule.url as Function).apply(null, match.slice(1)); + const cdnResponse = await fetch(cdnUrl); + + if (!cdnResponse.ok) { + throw error( + cdnResponse.status, + `Failed to fetch from CDN: ${cdnResponse.statusText}` + ); + } + + // Buffer the entire response to avoid potential streaming issues. + // This is less memory-efficient but more robust for debugging. + const body = await cdnResponse.arrayBuffer(); + const headers = new Headers(cdnResponse.headers); + + // The `fetch` API automatically decompresses content, so we need to remove + // the Content-Encoding header to avoid the browser trying to decompress it again. + // We also remove Content-Length because it's now incorrect for the decompressed body. + headers.delete('Content-Encoding'); + headers.delete('Content-Length'); + + // Ensure the Content-Type is set correctly, as it's crucial for the browser. + if (!headers.has('Content-Type')) { + headers.set( + 'Content-Type', + path.endsWith('.css') ? 'text/css' : 'application/javascript' + ); + } + + // Forward the response from the CDN + return new Response(body, { + status: cdnResponse.status, + statusText: cdnResponse.statusText, + headers: headers + }); + } catch (e: any) { + throw error(500, 'Failed to proxy vendor file'); + } + } + } + + const cookieName = `${EXPERIMENT_SESSION_COOKIE_PREFIX}${experimentId}`; + let experimentSessionId = cookies.get(cookieName); + + if (!experimentSessionId) { + // First request for this experiment. Create a new participant session. + experimentSessionId = randomUUID(); + const now = new Date(); + + await db.insert(schema.experimentSession).values({ + id: experimentSessionId, + experimentId, + createdAt: now, + updatedAt: now, + status: 'in progress', + ipAddress: getClientAddress(), + userAgent: request.headers.get('user-agent') ?? undefined + }); + + cookies.set(cookieName, experimentSessionId, { + path: `/public/multiplayer/run/${experimentId}`, + httpOnly: true, + secure: !dev, + maxAge: 60 * 60 * 24 * 365 // 1 year + }); + } else { + // subsequent requests, check if cookie is valid + const [session] = await db + .select() + .from(schema.experimentSession) + .where(eq(schema.experimentSession.id, experimentSessionId)); + if (!session) { + // invalid cookie, create new session + const newExperimentSessionId = randomUUID(); + const now = new Date(); + await db.insert(schema.experimentSession).values({ + id: newExperimentSessionId, + experimentId, + createdAt: now, + updatedAt: now, + status: 'in progress', + ipAddress: getClientAddress(), + userAgent: request.headers.get('user-agent') ?? undefined + }); + cookies.set(cookieName, newExperimentSessionId, { + path: `/public/multiplayer/run/${experimentId}`, + httpOnly: true, + secure: !dev, + maxAge: 60 * 60 * 24 * 365 // 1 year + }); + } + } + + const s3Prefix = `experiments/${experimentId}/`; + const filePath = path === '' ? 'index.html' : path; + const key = `${s3Prefix}${filePath}`; + + // Check if user is trying to access files outside of the experiment directory + if (!key.startsWith(s3Prefix)) { + throw error(403, 'Forbidden'); + } + + try { + const command = new GetObjectCommand({ + Bucket: S3_BUCKET, + Key: key + }); + const s3Response = await s3.send(command); + + if (!s3Response.Body) { + throw error(404, 'Not found'); + } + + const stream = s3Response.Body; + const contentType = + mime.lookup(filePath) || s3Response.ContentType || 'application/octet-stream'; + + if (filePath.endsWith('index.html')) { + const body = await stream.transformToString(); + + // For PsychoJS experiments, we need to set the base href to the experiment root + // so that relative paths like "stimuli/image.jpg" resolve correctly + const basePath = `/public/multiplayer/run/${experimentId}/`; + + // Get all files in the experiment directory to create a resource manifest + const listCommand = new ListObjectsV2Command({ + Bucket: S3_BUCKET, + Prefix: `experiments/${experimentId}/` + }); + const listResponse = await s3.send(listCommand); + + // Create resource manifest for PsychoJS + const resources = (listResponse.Contents || []) + .filter(obj => obj.Key && obj.Key !== `experiments/${experimentId}/index.html`) + .map(obj => { + const relativePath = obj.Key!.replace(`experiments/${experimentId}/`, ''); + return { + name: relativePath, + path: relativePath + }; + }); + + // Create the resource injection script that runs after PsychoJS loads + const resourceInjectionScript = ` + + `; + + const injectedBody = body + .replace(/]*>/, `$&`) + .replace(/<\/body>/, `${resourceInjectionScript}`); + + return new Response(injectedBody, { + headers: { + 'Content-Type': contentType, + 'Content-Length': Buffer.byteLength(injectedBody, 'utf8').toString() + } + }); + } + + const fileBuffer = await stream.transformToByteArray(); + return new Response(fileBuffer, { + headers: { 'Content-Type': contentType } + }); + } catch (e: any) { + if (e.name === 'NoSuchKey') { + throw error(404, 'File not found'); + } else { + throw error(500, `Error fetching from S3: ${e.message}`); + } + } +} \ No newline at end of file diff --git a/src/routes/public/run/[experimentId]/[...path]/+server.ts b/src/routes/public/run/[experimentId]/[...path]/+server.ts index 0bb6539..ab6ed73 100644 --- a/src/routes/public/run/[experimentId]/[...path]/+server.ts +++ b/src/routes/public/run/[experimentId]/[...path]/+server.ts @@ -10,7 +10,7 @@ import { S3_BUCKET } from '$env/static/private'; import { dev } from '$app/environment'; import { promises as fs } from 'fs'; -const PARTICIPANT_COOKIE_PREFIX = 'participant-session-'; +const EXPERIMENT_SESSION_COOKIE_PREFIX = 'experiment-session-'; // Rules for proxying vendor files to a CDN using regex to extract versions const vendorFileRules = [ @@ -99,22 +99,25 @@ export async function GET({ params, cookies, getClientAddress, request }) { } } - const cookieName = `${PARTICIPANT_COOKIE_PREFIX}${experimentId}`; - let participantSessionId = cookies.get(cookieName); + const cookieName = `${EXPERIMENT_SESSION_COOKIE_PREFIX}${experimentId}`; + let experimentSessionId = cookies.get(cookieName); - if (!participantSessionId) { + if (!experimentSessionId) { // First request for this experiment. Create a new participant session. - participantSessionId = randomUUID(); + experimentSessionId = randomUUID(); + const now = new Date(); - await db.insert(schema.participantSession).values({ - id: participantSessionId, + await db.insert(schema.experimentSession).values({ + id: experimentSessionId, experimentId, - createdAt: new Date(), + createdAt: now, + updatedAt: now, + status: 'in progress', ipAddress: getClientAddress(), userAgent: request.headers.get('user-agent') ?? undefined }); - cookies.set(cookieName, participantSessionId, { + cookies.set(cookieName, experimentSessionId, { path: `/public/run/${experimentId}`, httpOnly: true, secure: !dev, @@ -124,19 +127,22 @@ export async function GET({ params, cookies, getClientAddress, request }) { // subsequent requests, check if cookie is valid const [session] = await db .select() - .from(schema.participantSession) - .where(eq(schema.participantSession.id, participantSessionId)); + .from(schema.experimentSession) + .where(eq(schema.experimentSession.id, experimentSessionId)); if (!session) { // invalid cookie, create new session - const newParticipantSessionId = randomUUID(); - await db.insert(schema.participantSession).values({ - id: newParticipantSessionId, + const newExperimentSessionId = randomUUID(); + const now = new Date(); + await db.insert(schema.experimentSession).values({ + id: newExperimentSessionId, experimentId, - createdAt: new Date(), + createdAt: now, + updatedAt: now, + status: 'in progress', ipAddress: getClientAddress(), userAgent: request.headers.get('user-agent') ?? undefined }); - cookies.set(cookieName, newParticipantSessionId, { + cookies.set(cookieName, newExperimentSessionId, { path: `/public/run/${experimentId}`, httpOnly: true, secure: !dev,