Compare commits

..

6 Commits

Author SHA1 Message Date
Shaheed Azaad
c55454cf3d mid switch to Colyseum 2025-07-17 14:29:55 +02:00
Shaheed Azaad
e9565471cb basic MP functionality working 2025-07-17 10:09:20 +02:00
Shaheed Azaad
02734040cb multiplayer distinction 2025-07-16 16:46:27 +02:00
Shaheed Azaad
dc2f68a2b4 fixed delete 2025-07-16 12:30:04 +02:00
Shaheed Azaad
19ffd48ac0 added dummy settings 2025-07-15 15:29:33 +02:00
Shaheed Azaad
55401fd37b added menu 2025-07-15 15:23:54 +02:00
48 changed files with 6272 additions and 315 deletions

View File

@@ -1,12 +1,4 @@
---
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
Read, but don't edit @CLAUDE.md for all prompts

View File

@@ -1,9 +0,0 @@
---
description: Code rules
globs:
---
# Code
- This app uses Sveltekit. Where possible, the app should use reusable components and Svelte features for reactivity.
- This app is written in Typescript.

View File

@@ -1,10 +0,0 @@
---
description: Implementing database functions
globs:
---
# Your rule content
- This app uses DrizzleORM
- The app should use transactions and connection pooling where possible
- Utmost care needs to be taken to prevent SQL injection

View File

@@ -1,9 +0,0 @@
---
description: Styling
globs:
---
# Styling
- Everything should be designed using shadcn for svelte components
= No plain CSS should be written - only Tailwind

91
CLAUDE.md Normal file
View File

@@ -0,0 +1,91 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
cog-socket is a platform for hosting jsPsych and Pavlovia experiments with multiplayer support using WebSockets. Built with SvelteKit, it supports both single-player and multiplayer psychological experiments.
## Development Commands
### Database
- `npm run db:start` - Start PostgreSQL server via Docker Compose
- `npm run db:push` - Push schema changes to database
- `npm run db:migrate` - Run database migrations
- `npm run db:studio` - Open Drizzle Studio for database management
### Development Server
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run preview` - Preview production build
### Multiplayer Server
- `npm run multiplayer:dev` - Start multiplayer server in watch mode
- `npm run multiplayer:start` - Start multiplayer server in production mode
### Testing & Quality
- `npm run test` - Run all tests (unit + e2e)
- `npm run test:unit` - Run unit tests with Vitest
- `npm run test:e2e` - Run end-to-end tests with Playwright
- `npm run check` - Run svelte-check for TypeScript checking
- `npm run lint` - Run linting (Prettier + ESLint)
- `npm run format` - Format code with Prettier
## Architecture Overview
### Database Schema (DrizzleORM + PostgreSQL)
- **Users**: Authentication with Lucia, supports admin/user roles
- **Experiments**: jsPsych or PsychoJS experiments with multiplayer flag
- **Sessions**: User authentication sessions
- **ExperimentSessions**: Individual experiment runs with status tracking
### Multiplayer System (Colyseus)
- **Standalone server**: Runs on separate port (8080) from main app
- **ExperimentRoom**: Manages participant queuing, state synchronization
- **Session management**: Handles participant connections, disconnections, and queuing
- **Real-time communication**: WebSocket-based experiment state sharing
### SvelteKit Structure
- **Routes**:
- `/experiment/[id]` - Single experiment management
- `/public/multiplayer/run/[experimentId]` - Multiplayer experiment runner
- `/public/run/[experimentId]` - Single-player experiment runner
- `/api/experiments/[experimentId]` - Experiment API endpoints
- **Authentication**: Lucia-based session management with cookies
- **File handling**: S3 integration for experiment assets
### Key Components
- **ExperimentRoom.ts**: Core multiplayer room logic with participant management
- **ExperimentRoomState.ts**: Colyseus schema for synchronized state
- **colyseusServer.ts**: Multiplayer server setup and room management
- **sessionManager.ts**: Bridges SvelteKit app with Colyseus server
## Development Notes
### Multiplayer Development
- Multiplayer server runs independently on port 8080
- Experiments must have `multiplayer: true` flag in database
- Room capacity determined by `maxParticipants` field
- Queue system handles overflow participants
### Authentication
- Default admin user (admin/admin) created in development
- Session tokens stored in cookies, managed by Lucia
- Authentication middleware in `hooks.server.ts`
### Database Operations
- Use DrizzleORM for all database operations
- Schema defined in `src/lib/server/db/schema.ts`
- Migrations managed through drizzle-kit
### File Structure
- UI components in `src/lib/components/ui/` (bits-ui based)
- Server logic in `src/lib/server/`
- Route handlers follow SvelteKit conventions
- Multiplayer code isolated in `src/lib/server/multiplayer/`
## Testing Setup
- Unit tests: Vitest with Svelte browser testing
- E2E tests: Playwright
- TypeScript checking: svelte-check
- Linting: ESLint + Prettier with Svelte plugins

View File

@@ -21,6 +21,18 @@ services:
command: server --console-address ":9001" /data
volumes:
- minio-data:/data
chrome:
image: browserless/chrome:latest
ports:
- 3000:3000
environment:
- PREBOOT_CHROME=true
- CONNECTION_TIMEOUT=60000
- MAX_CONCURRENT_SESSIONS=10
- CHROME_REFRESH_TIME=2000
- DEBUG=*
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
pgdata:
minio-data:

View File

@@ -6,3 +6,6 @@ S3_ACCESS_KEY="minioadmin"
S3_SECRET_KEY="minioadmin"
S3_BUCKET="cog-socket"
BASE_URL="http://localhost:5173"
# Chrome for multiplayer functionality
CHROME_HOST="localhost"
CHROME_PORT="9222"

3334
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,9 @@
"db:start": "docker compose up",
"db:push": "drizzle-kit push",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"multiplayer:dev": "tsx --watch src/lib/server/multiplayer/standalone.ts",
"multiplayer:start": "tsx src/lib/server/multiplayer/standalone.ts"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
@@ -31,6 +33,7 @@
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22",
"@types/ws": "^8.18.1",
"@vitest/browser": "^3.2.3",
"drizzle-kit": "^0.30.2",
"eslint": "^9.18.0",
@@ -47,6 +50,7 @@
"sveltekit-superforms": "^2.27.1",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^4.0.0",
"tsx": "^4.20.3",
"tw-animate-css": "^1.3.5",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
@@ -57,6 +61,9 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.844.0",
"@colyseus/core": "^0.16.19",
"@colyseus/schema": "^3.0.42",
"@colyseus/ws-transport": "^0.16.5",
"@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
@@ -64,14 +71,19 @@
"bits-ui": "^2.8.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"colyseus": "^0.16.4",
"colyseus.js": "^0.16.19",
"dotenv": "^17.2.0",
"drizzle-orm": "^0.40.0",
"lucia": "^3.2.2",
"mime-types": "^3.0.1",
"morphdom": "^2.7.5",
"oslo": "^1.2.1",
"postgres": "^3.4.5",
"puppeteer-core": "^24.14.0",
"svelte-sonner": "^1.0.5",
"tailwind-merge": "^3.3.1",
"ws": "^8.18.3",
"zod": "^3.25.76"
}
}

View File

@@ -5,6 +5,11 @@ import * as schema from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import argon2 from '@node-rs/argon2';
import { dev } from '$app/environment';
import { sessionManager } from '$lib/server/multiplayer/sessionManager';
// Initialize session manager (this starts the WebSocket server)
console.log('Initializing multiplayer session manager...');
// The sessionManager is initialized when imported
const ensureDefaultAdmin = async () => {
if (!dev) return;

View File

@@ -0,0 +1,28 @@
import Root from "./navigation-menu.svelte";
import Content from "./navigation-menu-content.svelte";
import Indicator from "./navigation-menu-indicator.svelte";
import Item from "./navigation-menu-item.svelte";
import Link from "./navigation-menu-link.svelte";
import List from "./navigation-menu-list.svelte";
import Trigger from "./navigation-menu-trigger.svelte";
import Viewport from "./navigation-menu-viewport.svelte";
export {
Root,
Content,
Indicator,
Item,
Link,
List,
Trigger,
Viewport,
//
Root as NavigationMenuRoot,
Content as NavigationMenuContent,
Indicator as NavigationMenuIndicator,
Item as NavigationMenuItem,
Link as NavigationMenuLink,
List as NavigationMenuList,
Trigger as NavigationMenuTrigger,
Viewport as NavigationMenuViewport,
};

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.ContentProps = $props();
</script>
<NavigationMenuPrimitive.Content
bind:ref
data-slot="navigation-menu-content"
class={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 left-0 top-0 w-full md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.IndicatorProps = $props();
</script>
<NavigationMenuPrimitive.Indicator
bind:ref
data-slot="navigation-menu-indicator"
class={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...restProps}
>
<div class="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md"></div>
</NavigationMenuPrimitive.Indicator>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.ItemProps = $props();
</script>
<NavigationMenuPrimitive.Item
bind:ref
data-slot="navigation-menu-item"
class={cn("relative", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.LinkProps = $props();
</script>
<NavigationMenuPrimitive.Link
bind:ref
data-slot="navigation-menu-link"
class={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm outline-none transition-all focus-visible:outline-1 focus-visible:ring-[3px] [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.ListProps = $props();
</script>
<NavigationMenuPrimitive.List
bind:ref
data-slot="navigation-menu-list"
class={cn("group flex flex-1 list-none items-center justify-center gap-1", className)}
{...restProps}
/>

View File

@@ -0,0 +1,34 @@
<script lang="ts" module>
import { cn } from "$lib/utils.js";
import { tv } from "tailwind-variants";
export const navigationMenuTriggerStyle = tv({
base: "bg-background hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 group inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50",
});
</script>
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: NavigationMenuPrimitive.TriggerProps = $props();
</script>
<NavigationMenuPrimitive.Trigger
bind:ref
data-slot="navigation-menu-trigger"
class={cn(navigationMenuTriggerStyle(), "group", className)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon
class="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.ViewportProps = $props();
</script>
<div class={cn("absolute left-0 top-full isolate z-50 flex justify-center")}>
<NavigationMenuPrimitive.Viewport
bind:ref
data-slot="navigation-menu-viewport"
class={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--bits-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--bits-navigation-menu-viewport-width)]",
className
)}
{...restProps}
/>
</div>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import NavigationMenuViewport from "./navigation-menu-viewport.svelte";
let {
ref = $bindable(null),
class: className,
viewport = true,
children,
...restProps
}: NavigationMenuPrimitive.RootProps & {
viewport?: boolean;
} = $props();
</script>
<NavigationMenuPrimitive.Root
bind:ref
data-slot="navigation-menu"
data-viewport={viewport}
class={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...restProps}
>
{@render children?.()}
{#if viewport}
<NavigationMenuViewport />
{/if}
</NavigationMenuPrimitive.Root>

View File

@@ -26,20 +26,26 @@ export const experiment = pgTable('experiment', {
.references(() => user.id),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull(),
multiplayer: boolean('multiplayer').default(false).notNull(),
maxParticipants: integer('max_participants').default(1).notNull(),
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;

View File

@@ -0,0 +1,197 @@
import { Server, matchMaker } from 'colyseus';
import { ExperimentRoom } from './rooms/ExperimentRoom.js';
import { WebSocketTransport } from '@colyseus/ws-transport';
import { db } from './db.js';
import * as schema from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { createServer } from 'http';
export class MultiplayerServer {
private server: Server;
private port: number;
constructor(port: number = 8080) {
this.port = port;
this.server = new Server({
transport: new WebSocketTransport({
pingInterval: 30000,
pingMaxRetries: 3
}),
presence: undefined, // Use default in-memory presence
driver: undefined, // Use default in-memory driver
gracefullyShutdown: false
});
// Add CORS middleware using Colyseus's built-in middleware support
this.server.use(undefined, (req, res, next) => {
const origin = req.headers.origin;
const isDev = process.env.NODE_ENV !== 'production';
// Always set CORS headers for development
if (isDev) {
res.setHeader('Access-Control-Allow-Origin', '*');
} else {
const allowedOrigins = ['https://your-production-domain.com']; // Update with actual production domain
if (origin && allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
res.setHeader('Access-Control-Allow-Credentials', 'true');
// Handle preflight requests
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
next();
});
this.setupRooms();
}
private setupRooms() {
// Define the ExperimentRoom
this.server.define('experiment_room', ExperimentRoom);
// Set up matchmaking
this.server.onShutdown(() => {
console.log('[MultiplayerServer] Server shutting down...');
});
}
async start() {
try {
await this.server.listen(this.port);
console.log(`[MultiplayerServer] Colyseus server listening on port ${this.port}`);
} catch (error) {
console.error('[MultiplayerServer] Failed to start server:', error);
throw error;
}
}
async stop() {
try {
await this.server.gracefullyShutdown();
console.log('[MultiplayerServer] Server stopped gracefully');
} catch (error) {
console.error('[MultiplayerServer] Error stopping server:', error);
throw error;
}
}
getServer() {
return this.server;
}
// Utility method to find or create a room for an experiment
async findOrCreateRoom(experimentId: string, clientOptions: any = {}) {
try {
// Get experiment details to determine max participants
const experiment = await db.query.experiment.findFirst({
where: eq(schema.experiment.id, experimentId)
});
if (!experiment) {
throw new Error(`Experiment ${experimentId} not found`);
}
if (!experiment.multiplayer) {
throw new Error(`Experiment ${experimentId} is not a multiplayer experiment`);
}
// Try to find existing room for this experiment
const rooms = await matchMaker.query({
name: 'experiment_room',
options: { experimentId }
});
// Find a room that has space or is waiting
let availableRoom = rooms.find(room =>
room.clients < experiment.maxParticipants &&
room.metadata?.status !== 'completed'
);
if (!availableRoom) {
// Create new room
console.log(`[MultiplayerServer] Creating new room for experiment ${experimentId}`);
availableRoom = await matchMaker.createRoom('experiment_room', {
experimentId,
maxParticipants: experiment.maxParticipants
});
}
return availableRoom;
} catch (error) {
console.error('[MultiplayerServer] Error finding/creating room:', error);
throw error;
}
}
// Get room status
async getRoomStatus(roomId: string) {
try {
const room = matchMaker.getRoomById(roomId);
if (!room) {
return null;
}
return {
roomId: room.roomId,
clients: room.clients,
maxClients: room.maxClients,
metadata: room.metadata
};
} catch (error) {
console.error('[MultiplayerServer] Error getting room status:', error);
return null;
}
}
// Get all rooms for an experiment
async getExperimentRooms(experimentId: string) {
try {
const rooms = await matchMaker.query({
name: 'experiment_room',
options: { experimentId }
});
return rooms.map(room => ({
roomId: room.roomId,
clients: room.clients,
maxClients: room.maxClients,
metadata: room.metadata
}));
} catch (error) {
console.error('[MultiplayerServer] Error getting experiment rooms:', error);
return [];
}
}
}
// Singleton instance
let multiplayerServer: MultiplayerServer | null = null;
export function getMultiplayerServer(port: number = 8080): MultiplayerServer {
if (!multiplayerServer) {
multiplayerServer = new MultiplayerServer(port);
}
return multiplayerServer;
}
export function startMultiplayerServer(port: number = 8080): Promise<MultiplayerServer> {
const server = getMultiplayerServer(port);
return server.start().then(() => server);
}
export function stopMultiplayerServer(): Promise<void> {
if (multiplayerServer) {
return multiplayerServer.stop();
}
return Promise.resolve();
}

View File

@@ -0,0 +1,13 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from '../db/schema.js';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
// Database connection
const client = postgres(process.env.DATABASE_URL || 'postgresql://postgres:password@localhost:5432/postgres');
export const db = drizzle(client, { schema });
console.log('[Multiplayer DB] Connected to database');

View File

@@ -0,0 +1,40 @@
import { startMultiplayerServer, getMultiplayerServer } from './colyseusServer.js';
const PORT = parseInt(process.env.MULTIPLAYER_PORT || '8080');
// Start the multiplayer server
startMultiplayerServer(PORT)
.then((server) => {
console.log(`[Multiplayer] Server started successfully on port ${PORT}`);
})
.catch((error) => {
console.error('[Multiplayer] Failed to start server:', error);
process.exit(1);
});
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('[Multiplayer] Received SIGINT, shutting down gracefully...');
try {
const server = getMultiplayerServer();
await server.stop();
console.log('[Multiplayer] Server stopped gracefully');
process.exit(0);
} catch (error) {
console.error('[Multiplayer] Error during shutdown:', error);
process.exit(1);
}
});
process.on('SIGTERM', async () => {
console.log('[Multiplayer] Received SIGTERM, shutting down gracefully...');
try {
const server = getMultiplayerServer();
await server.stop();
console.log('[Multiplayer] Server stopped gracefully');
process.exit(0);
} catch (error) {
console.error('[Multiplayer] Error during shutdown:', error);
process.exit(1);
}
});

View File

@@ -0,0 +1,244 @@
import { Room, matchMaker, type Client } from 'colyseus';
import { ExperimentRoomState, Participant } from '../schemas/ExperimentRoomState.js';
import { db } from '../db.js';
import * as schema from '../../db/schema.js';
import { eq } from 'drizzle-orm';
export class ExperimentRoom extends Room<ExperimentRoomState> {
maxClients = 1; // Will be set dynamically from database
async onCreate(options: { experimentId: string }) {
this.setState(new ExperimentRoomState());
// Get experiment details from database
const experiment = await db.query.experiment.findFirst({
where: eq(schema.experiment.id, options.experimentId)
});
if (!experiment) {
throw new Error(`Experiment ${options.experimentId} not found`);
}
if (!experiment.multiplayer) {
throw new Error(`Experiment ${options.experimentId} is not a multiplayer experiment`);
}
// Configure room
this.maxClients = experiment.maxParticipants;
this.state.experimentId = options.experimentId;
this.state.maxParticipants = experiment.maxParticipants;
this.state.createdAt = Date.now();
console.log(`[ExperimentRoom] Created room for experiment ${options.experimentId} (max: ${experiment.maxParticipants})`);
// Set up message handlers
this.onMessage('ready', (client, message) => {
this.handleParticipantReady(client, message);
});
this.onMessage('experiment_action', (client, message) => {
this.handleExperimentAction(client, message);
});
this.onMessage('experiment_state', (client, message) => {
this.handleExperimentState(client, message);
});
// Auto-dispose empty rooms after 30 minutes
this.autoDispose = false;
setTimeout(() => {
if (this.clients.length === 0) {
this.disconnect();
}
}, 30 * 60 * 1000);
}
async onJoin(client: Client, options: any) {
console.log(`[ExperimentRoom] Client ${client.sessionId} attempting to join room ${this.roomId}`);
// Check if room is full
if (this.state.currentParticipants >= this.state.maxParticipants) {
// Add to queue
this.state.queue.push(client.sessionId);
console.log(`[ExperimentRoom] Client ${client.sessionId} added to queue (position: ${this.state.queue.length})`);
// Notify client they're in queue
client.send('queued', {
position: this.state.queue.length,
message: 'You are in the queue. You will be admitted when a spot opens up.'
});
// Don't create participant yet - they're just in queue
return;
}
// Create participant
const participant = new Participant();
participant.id = client.sessionId;
participant.sessionId = client.sessionId;
participant.joinedAt = Date.now();
participant.userAgent = options.userAgent || '';
participant.ipAddress = options.ipAddress || '';
this.state.participants.set(client.sessionId, participant);
this.state.currentParticipants++;
console.log(`[ExperimentRoom] Client ${client.sessionId} joined (${this.state.currentParticipants}/${this.state.maxParticipants})`);
// Check if experiment should start
this.checkExperimentStart();
// Send current state to client
client.send('joined', {
participantId: client.sessionId,
currentParticipants: this.state.currentParticipants,
maxParticipants: this.state.maxParticipants,
status: this.state.status
});
}
async onLeave(client: Client, consented: boolean) {
console.log(`[ExperimentRoom] Client ${client.sessionId} left (consented: ${consented})`);
// Remove from participants if they were participating
if (this.state.participants.has(client.sessionId)) {
this.state.participants.delete(client.sessionId);
this.state.currentParticipants--;
// Process queue if there's space
this.processQueue();
} else {
// Remove from queue
const queueIndex = this.state.queue.indexOf(client.sessionId);
if (queueIndex !== -1) {
this.state.queue.splice(queueIndex, 1);
// Notify remaining queue members of new positions
this.notifyQueuePositions();
}
}
// If experiment is running and someone left, handle accordingly
if (this.state.status === 'running' && this.state.currentParticipants === 0) {
this.state.status = 'completed';
this.state.completedAt = Date.now();
console.log(`[ExperimentRoom] Experiment completed - all participants left`);
}
}
private async processQueue() {
if (this.state.queue.length === 0 || this.state.currentParticipants >= this.state.maxParticipants) {
return;
}
const nextSessionId = this.state.queue.shift();
if (nextSessionId) {
// Find the client in the room
const client = this.clients.find(c => c.sessionId === nextSessionId);
if (client) {
// Move from queue to participants
const participant = new Participant();
participant.id = client.sessionId;
participant.sessionId = client.sessionId;
participant.joinedAt = Date.now();
this.state.participants.set(client.sessionId, participant);
this.state.currentParticipants++;
console.log(`[ExperimentRoom] Client ${client.sessionId} moved from queue to participants`);
// Notify client they're now participating
client.send('admitted', {
participantId: client.sessionId,
currentParticipants: this.state.currentParticipants,
maxParticipants: this.state.maxParticipants,
status: this.state.status
});
// Check if experiment should start
this.checkExperimentStart();
}
// Update queue positions for remaining clients
this.notifyQueuePositions();
}
}
private notifyQueuePositions() {
this.state.queue.forEach((sessionId, index) => {
const client = this.clients.find(c => c.sessionId === sessionId);
if (client) {
client.send('queue_update', {
position: index + 1,
message: `You are #${index + 1} in the queue`
});
}
});
}
private checkExperimentStart() {
// Auto-start when room is full (this can be customized)
if (this.state.currentParticipants === this.state.maxParticipants && this.state.status === 'waiting') {
this.startExperiment();
}
}
private startExperiment() {
this.state.status = 'running';
this.state.startedAt = Date.now();
console.log(`[ExperimentRoom] Experiment started for ${this.state.experimentId}`);
// Notify all participants
this.broadcast('experiment_started', {
message: 'Experiment is now starting',
participants: this.state.currentParticipants
});
}
private handleParticipantReady(client: Client, message: any) {
const participant = this.state.participants.get(client.sessionId);
if (participant) {
participant.isReady = true;
// Check if all participants are ready
const allReady = Array.from(this.state.participants.values()).every(p => p.isReady);
if (allReady && this.state.status === 'waiting') {
this.startExperiment();
}
}
}
private handleExperimentAction(client: Client, message: any) {
// Broadcast experiment actions to all participants
this.broadcast('experiment_action', {
participantId: client.sessionId,
action: message.action,
data: message.data,
timestamp: Date.now()
}, { except: client });
}
private handleExperimentState(client: Client, message: any) {
// Update experiment state
this.state.experimentState = JSON.stringify(message.state);
// Broadcast state update to all participants
this.broadcast('experiment_state_update', {
state: message.state,
updatedBy: client.sessionId,
timestamp: Date.now()
}, { except: client });
}
async onDispose() {
console.log(`[ExperimentRoom] Room ${this.roomId} disposed`);
// Mark experiment as completed if it was running
if (this.state.status === 'running') {
this.state.status = 'completed';
this.state.completedAt = Date.now();
}
}
}

View File

@@ -0,0 +1,24 @@
import { Schema, type, MapSchema } from '@colyseus/schema';
export class Participant extends Schema {
@type('string') id: string = '';
@type('string') sessionId: string = '';
@type('number') joinedAt: number = 0;
@type('boolean') isReady: boolean = false;
@type('boolean') isConnected: boolean = true;
@type('string') userAgent?: string = '';
@type('string') ipAddress?: string = '';
}
export class ExperimentRoomState extends Schema {
@type('string') experimentId: string = '';
@type('string') status: 'waiting' | 'running' | 'completed' = 'waiting';
@type('number') maxParticipants: number = 1;
@type('number') currentParticipants: number = 0;
@type('number') createdAt: number = 0;
@type('number') startedAt?: number = 0;
@type('number') completedAt?: number = 0;
@type({ map: Participant }) participants = new MapSchema<Participant>();
@type(['string']) queue: string[] = [];
@type('string') experimentState?: string = ''; // JSON string of current experiment state
}

View File

@@ -0,0 +1,110 @@
import puppeteer, { Browser, Page } from 'puppeteer-core';
import { env } from '$env/dynamic/private';
import { EventEmitter } from 'events';
interface Session {
page: Page;
browser: Browser;
}
class SessionManager extends EventEmitter {
private sessions: Map<string, Session> = new Map();
private screenshotIntervals: Map<string, NodeJS.Timeout> = new Map();
constructor() {
super();
this.cleanup = this.cleanup.bind(this);
process.on('exit', this.cleanup);
process.on('SIGINT', this.cleanup);
process.on('SIGTERM', this.cleanup);
process.on('uncaughtException', this.cleanup);
}
async createSession(experimentId: string): Promise<void> {
if (this.sessions.has(experimentId)) {
console.log(`Session already exists for experiment ${experimentId}`);
return;
}
console.log(`Creating session for experiment ${experimentId}`);
try {
const browser = await puppeteer.connect({
browserWSEndpoint: `ws://${env.CHROME_HOST}:${env.CHROME_PORT}`
});
const page = await browser.newPage();
// Set a large viewport size to capture the whole experiment.
// This might need to be adjusted based on the experiment's design.
await page.setViewport({ width: 1920, height: 1080 });
const experimentUrl = `http://localhost:5173/public/run/${experimentId}/`;
await page.goto(experimentUrl, { waitUntil: 'networkidle0' });
this.sessions.set(experimentId, { page, browser });
// Start taking screenshots every 500ms
const interval = setInterval(async () => {
if (!this.sessions.has(experimentId)) {
clearInterval(interval);
return;
}
try {
const screenshot = await page.screenshot({ encoding: 'base64' });
this.emit(`screenshot:${experimentId}`, screenshot);
} catch (error) {
console.error(`Error taking screenshot for experiment ${experimentId}:`, error);
await this.cleanupSession(experimentId);
}
}, 500);
this.screenshotIntervals.set(experimentId, interval);
page.on('close', () => {
console.log(`Page closed for experiment ${experimentId}`);
this.cleanupSession(experimentId);
});
} catch (error) {
console.error(`Error creating session for experiment ${experimentId}:`, error);
await this.cleanupSession(experimentId);
}
}
getSession(experimentId: string): Session | undefined {
return this.sessions.get(experimentId);
}
async cleanupSession(experimentId: string): Promise<void> {
console.log(`Cleaning up session for experiment ${experimentId}`);
const interval = this.screenshotIntervals.get(experimentId);
if (interval) {
clearInterval(interval);
this.screenshotIntervals.delete(experimentId);
}
const session = this.sessions.get(experimentId);
if (session) {
try {
if (!session.page.isClosed()) {
await session.page.close();
}
} catch (error) {
console.error(`Error closing page for experiment ${experimentId}:`, error);
}
this.sessions.delete(experimentId);
}
}
async cleanup(): Promise<void> {
console.log('Cleaning up all sessions...');
const allSessions = Array.from(this.sessions.keys());
for (const experimentId of allSessions) {
await this.cleanupSession(experimentId);
}
console.log('All sessions cleaned up.');
process.exit(0);
}
}
export const sessionManager = new SessionManager();

View File

@@ -0,0 +1,32 @@
import { startMultiplayerServer } from './colyseusServer.js';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
const PORT = parseInt(process.env.MULTIPLAYER_PORT || '8080');
console.log('[Multiplayer] Starting standalone Colyseus server...');
// Start the multiplayer server
startMultiplayerServer(PORT)
.then((server) => {
console.log(`[Multiplayer] Server started successfully on port ${PORT}`);
console.log(`[Multiplayer] WebSocket endpoint: ws://localhost:${PORT}`);
console.log(`[Multiplayer] Ready to accept connections`);
})
.catch((error) => {
console.error('[Multiplayer] Failed to start server:', error);
process.exit(1);
});
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('[Multiplayer] Received SIGINT, shutting down gracefully...');
process.exit(0);
});
process.on('SIGTERM', async () => {
console.log('[Multiplayer] Received SIGTERM, shutting down gracefully...');
process.exit(0);
});

View File

@@ -2,8 +2,12 @@ import { S3Client } from '@aws-sdk/client-s3';
import {
S3_ENDPOINT,
S3_ACCESS_KEY,
S3_SECRET_KEY
S3_SECRET_KEY,
S3_BUCKET
} from '$env/static/private';
import { ListObjectsV2Command } from '@aws-sdk/client-s3';
import type { ListObjectsV2CommandOutput } from '@aws-sdk/client-s3';
import { HeadObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({
region: 'us-east-1', // MinIO ignores region but AWS SDK requires it
@@ -15,4 +19,24 @@ const s3 = new S3Client({
forcePathStyle: true // needed for MinIO
});
/**
* Returns the total storage used by a user (in bytes) by summing all S3 objects under their userId prefix.
*/
export async function getUserStorageUsage(userId: string): Promise<number> {
const BUCKET = S3_BUCKET!;
try {
const result = await s3.send(new HeadObjectCommand({
Bucket: BUCKET,
Key: userId,
}));
return result.ContentLength || 0;
} catch (err: any) {
// If the file does not exist, return 0
if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
return 0;
}
throw err;
}
}
export default s3;

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import '../app.css';
import { Home, FlaskConical } from '@lucide/svelte';
import { Home, FlaskConical, Cog } from '@lucide/svelte';
import { PUBLIC_APP_NAME } from '$env/static/public';
import * as NavigationMenu from '$lib/components/ui/navigation-menu/index.js';
let { children } = $props();
@@ -17,8 +18,31 @@
<span class="font-bold">{PUBLIC_APP_NAME || 'Cog Socket App'}</span>
</a>
<div class="flex flex-1 items-center justify-end space-x-2">
<nav class="flex items-center">
<nav class="flex items-center gap-2">
<!-- Top bar right side elements, e.g., user menu, settings -->
<NavigationMenu.Root viewport={false}>
<NavigationMenu.List>
<NavigationMenu.Item>
<NavigationMenu.Trigger>
<Cog class="size-5" />
<span class="sr-only">Settings</span>
</NavigationMenu.Trigger>
<NavigationMenu.Content class="right-0 left-auto">
<ul class="grid w-[200px] gap-2 p-2">
<li>
<NavigationMenu.Link href="/settings/profile">Profile</NavigationMenu.Link>
</li>
<li>
<NavigationMenu.Link href="/settings">Settings</NavigationMenu.Link>
</li>
<li>
<NavigationMenu.Link href="/settings/subscription">Subscription</NavigationMenu.Link>
</li>
</ul>
</NavigationMenu.Content>
</NavigationMenu.Item>
</NavigationMenu.List>
</NavigationMenu.Root>
</nav>
</div>
</div>

View File

@@ -64,6 +64,8 @@ async function handleSubmit() {
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Type</TableHead>
<TableHead>Multiplayer</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
@@ -80,6 +82,8 @@ async function handleSubmit() {
<a href={`/experiment/${exp.id}`} class="text-primary underline hover:text-primary/80">{exp.name}</a>
</TableCell>
<TableCell>{exp.description}</TableCell>
<TableCell>{exp.type}</TableCell>
<TableCell>{exp.multiplayer ? 'Yes' : 'No'}</TableCell>
<TableCell>{new Date(exp.createdAt).toLocaleString()}</TableCell>
<TableCell>
<Button

View File

@@ -14,9 +14,14 @@ const s3 = new S3Client({
});
const BUCKET = S3_BUCKET!;
export async function GET({ params }) {
export async function GET({ params, locals, url }) {
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = params;
const prefix = `experiments/${id}/`;
const userId = locals.user.id;
const type = url?.searchParams?.get('type') || 'experiment_files';
const prefix = `${userId}/${id}/${type}/`;
const result = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET, Prefix: prefix }));
const files = (result.Contents || []).map(obj => ({
key: obj.Key,
@@ -27,15 +32,18 @@ export async function GET({ params }) {
return json({ files });
}
export async function POST({ params, request }) {
console.log(params);
export async function POST({ params, request, locals, url }) {
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = params;
const userId = locals.user.id;
const type = url?.searchParams?.get('type') || 'experiment_files';
const data = await request.formData();
const file = data.get('file');
const relativePath = data.get('relativePath');
if (!file || typeof file === 'string') throw error(400, 'No file uploaded');
const key = `experiments/${id}/${relativePath}`;
console.log(key);
const key = `${userId}/${id}/${type}/${relativePath}`;
await s3.send(new PutObjectCommand({
Bucket: BUCKET,
Key: key,
@@ -45,13 +53,18 @@ export async function POST({ params, request }) {
return json({ success: true, key });
}
export async function DELETE({ params, url }) {
export async function DELETE({ params, url, locals }) {
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = params;
const userId = locals.user.id;
const type = url?.searchParams?.get('type') || 'experiment_files';
const key = url.searchParams.get('key');
const prefix = url.searchParams.get('prefix');
if (prefix) {
// Delete all objects with this prefix
const fullPrefix = `experiments/${id}/${prefix}`;
const fullPrefix = `${userId}/${id}/${type}/${prefix}`;
const result = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET, Prefix: fullPrefix }));
const objects = (result.Contents || []).map(obj => ({ Key: obj.Key }));
if (objects.length > 0) {
@@ -65,6 +78,7 @@ export async function DELETE({ params, url }) {
return json({ success: true, deleted: objects.length });
}
if (!key) throw error(400, 'Missing key or prefix');
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));
const fullKey = `${userId}/${id}/${type}/${key}`;
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: fullKey }));
return json({ success: true });
}

View File

@@ -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<number>`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 });
}
}

View File

@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import * as schema from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
export async function GET({ params }) {
const { experimentId } = params;
try {
const experiment = await db.query.experiment.findFirst({
where: eq(schema.experiment.id, experimentId)
});
if (!experiment) {
throw error(404, 'Experiment not found');
}
return json({
id: experiment.id,
name: experiment.name,
description: experiment.description,
multiplayer: experiment.multiplayer,
maxParticipants: experiment.maxParticipants,
type: experiment.type,
createdAt: experiment.createdAt
});
} catch (err) {
console.error('Error fetching experiment:', err);
throw error(500, 'Internal server error');
}
}

View File

@@ -0,0 +1,10 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { getUserStorageUsage } from '$lib/server/s3';
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const usage = await getUserStorageUsage(locals.user.id);
return json({ usage });
};

View File

@@ -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,234 +16,117 @@ 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) {
const url = `/api/experiment/${experimentId}/files?key=${encodeURIComponent(key)}`;
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;
}
let expanded = new Set<string>();
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 folder, send a delete request with a prefix param
if (isFolder) {
const url = `/api/experiment/${experimentId}/files?prefix=${encodeURIComponent(key)}`;
console.log('Deleting folder with URL:', url);
const res = await fetch(url, { method: 'DELETE' });
console.log('Folder delete response:', res.status, res.ok);
if (res.ok) await fetchFiles();
} else {
console.log('Deleting file with key:', key);
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.';
}
}
</script>
{#if experiment}
<div class="flex flex-col min-h-screen bg-background py-8">
<Card class="mb-8 w-full max-w-xl mx-auto">
<CardHeader>
<CardTitle>Experiment Details</CardTitle>
</CardHeader>
<CardContent>
<div class="mb-4">
<Label>ID</Label>
<div class="text-sm text-muted-foreground mb-2">{experiment.id}</div>
</div>
<form on:submit|preventDefault={handleSave} class="space-y-4">
<div>
<Label for="name">Name</Label>
<Input id="name" type="text" bind:value={name} required />
</div>
<div>
<Label for="description">Description</Label>
<Input id="description" type="text" bind:value={description} />
</div>
<div>
<Label>Created At</Label>
<div class="text-sm text-muted-foreground mb-2">{new Date(experiment.createdAt).toLocaleString()}</div>
</div>
{#if error}
<div class="text-red-500 text-sm">{error}</div>
{/if}
{#if success}
<div class="text-green-600 text-sm">{success}</div>
{/if}
<Button type="submit">Save Changes</Button>
</form>
</CardContent>
</Card>
<Card class="mb-8 w-full max-w-xl mx-auto">
<CardHeader>
<CardTitle>Public Link</CardTitle>
</CardHeader>
<CardContent>
<p class="text-sm text-muted-foreground mb-2">
Share this link with your participants to run the experiment.
</p>
<div class="flex items-center space-x-2">
<Input id="public-link" type="text" readonly value="{`${origin}/public/run/${experiment.id}`}" />
<Button on:click={copyLink}>Copy</Button>
</div>
{#if copied}
<p class="text-green-600 text-sm mt-2">Copied to clipboard!</p>
{/if}
</CardContent>
</Card>
<Card class="w-full max-w-xl mx-auto">
<CardHeader>
<CardTitle>Experiment Files</CardTitle>
</CardHeader>
<CardContent>
<div class="mt-8">
<Label>Experiment Files</Label>
<div
class="border-2 border-dashed rounded-md p-4 mb-4 text-center cursor-pointer bg-muted hover:bg-accent transition"
on:click={() => document.getElementById('file-input')?.click()}
>
<div>Click to select files or a folder to upload (folder structure will be preserved)</div>
<input id="file-input" type="file" multiple webkitdirectory class="hidden" on:change={handleFileInput} />
</div>
{#if uploading}
<div class="text-blue-600 text-sm mb-2">Uploading...</div>
{/if}
{#if uploadError}
<div class="text-red-500 text-sm mb-2">{uploadError}</div>
{/if}
<ul class="divide-y divide-border">
<FileBrowser
tree={buildFileTree(files)}
parentPath=""
{expanded}
onToggle={toggleFolder}
onDelete={deleteFileOrFolder}
/>
{#if files.length === 0}
<li class="text-muted-foreground text-sm py-2">No files uploaded yet.</li>
{/if}
</ul>
</div>
</CardContent>
</Card>
<div class="w-full max-w-xl mx-auto mb-8">
<div class="flex border-b mb-4">
<button class="px-4 py-2 -mb-px border-b-2 font-medium focus:outline-none transition-colors duration-200" class:!border-primary={activeTab === 'info'} class:border-transparent={activeTab !== 'info'} on:click={() => activeTab = 'info'}>Info</button>
<button class="px-4 py-2 -mb-px border-b-2 font-medium focus:outline-none transition-colors duration-200" class:!border-primary={activeTab === 'files'} class:border-transparent={activeTab !== 'files'} on:click={() => activeTab = 'files'}>Files</button>
<button class="px-4 py-2 -mb-px border-b-2 font-medium focus:outline-none transition-colors duration-200" class:!border-primary={activeTab === 'sessions'} class:border-transparent={activeTab !== 'sessions'} on:click={() => activeTab = 'sessions'}>Sessions</button>
</div>
{#if activeTab === 'info'}
<Card class="w-full">
<CardHeader>
<CardTitle>Experiment Details</CardTitle>
</CardHeader>
<CardContent>
<div class="mb-4">
<Label>ID</Label>
<div class="text-sm text-muted-foreground mb-2">{experiment.id}</div>
</div>
<div class="mb-4">
<Label>Type</Label>
<div class="text-sm text-muted-foreground mb-2">{experiment.type}</div>
</div>
<div class="mb-4">
<Label>Multiplayer</Label>
<div class="text-sm text-muted-foreground mb-2">{experiment.multiplayer ? 'Yes' : 'No'}</div>
</div>
<form on:submit|preventDefault={handleSave} class="space-y-4">
<div>
<Label for="name">Name</Label>
<Input id="name" type="text" bind:value={name} required />
</div>
<div>
<Label for="description">Description</Label>
<Input id="description" type="text" bind:value={description} />
</div>
<div>
<Label>Created At</Label>
<div class="text-sm text-muted-foreground mb-2">{new Date(experiment.createdAt).toLocaleString()}</div>
</div>
{#if error}
<div class="text-red-500 text-sm">{error}</div>
{/if}
{#if success}
<div class="text-green-600 text-sm">{success}</div>
{/if}
<Button type="submit">Save Changes</Button>
</form>
<div class="mt-8">
<Label>Public Link</Label>
<div class="flex items-center space-x-2 mt-2">
<Input id="public-link" type="text" readonly value={publicLink} />
<Button on:click={copyLink}>Copy</Button>
</div>
{#if copied}
<p class="text-green-600 text-sm mt-2">Copied to clipboard!</p>
{/if}
</div>
</CardContent>
</Card>
{:else if activeTab === 'files'}
<FileManager {experimentId} />
{:else if activeTab === 'sessions'}
<ExperimentSessions {experimentId} />
{/if}
</div>
</div>
{:else if error}
<div class="flex justify-center items-center min-h-screen bg-background">

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Button } from '$lib/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '$lib/components/ui/table';
export let experimentId: string;
let sessions: any[] = [];
let page = 1;
let limit = 10;
let total = 0;
let loading = false;
async function fetchSessions() {
loading = true;
const res = await fetch(
`/api/experiment/${experimentId}/sessions?page=${page}&limit=${limit}`
);
if (res.ok) {
const data = await res.json();
sessions = data.sessions;
total = data.total;
}
loading = false;
}
function nextPage() {
if (page * limit < total) {
page++;
fetchSessions();
}
}
function prevPage() {
if (page > 1) {
page--;
fetchSessions();
}
}
onMount(() => {
if (experimentId) {
fetchSessions();
}
});
</script>
<div class="space-y-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>Session ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Updated At</TableHead>
<TableHead>External ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#if loading}
<TableRow>
<TableCell colspan={5} class="text-center">Loading...</TableCell>
</TableRow>
{:else if sessions.length === 0}
<TableRow>
<TableCell colspan={5} class="text-center">No sessions found.</TableCell>
</TableRow>
{:else}
{#each sessions as session}
<TableRow>
<TableCell>{session.id}</TableCell>
<TableCell>{session.status}</TableCell>
<TableCell>{new Date(session.createdAt).toLocaleString()}</TableCell>
<TableCell>{new Date(session.updatedAt).toLocaleString()}</TableCell>
<TableCell>{session.externalId || 'N/A'}</TableCell>
</TableRow>
{/each}
{/if}
</TableBody>
</Table>
<div class="flex justify-between items-center">
<div>
<p class="text-sm text-muted-foreground">
Showing {Math.min((page - 1) * limit + 1, total)} to {Math.min(page * limit, total)} of {total} sessions
</p>
</div>
<div class="flex space-x-2">
<Button on:click={prevPage} disabled={page === 1}>Previous</Button>
<Button on:click={nextPage} disabled={page * limit >= total}>Next</Button>
</div>
</div>
</div>

View File

@@ -1,45 +1,91 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import FileIcon from '@lucide/svelte/icons/file';
import FolderIcon from '@lucide/svelte/icons/folder';
import FolderOpenIcon from '@lucide/svelte/icons/folder-open';
import TrashIcon from '@lucide/svelte/icons/trash';
import DownloadIcon from '@lucide/svelte/icons/download';
export let tree: any;
export let parentPath = '';
export let expanded: Set<string>;
export let onToggle: (path: string) => void;
export let onDelete: (key: string, isFolder: boolean) => void;
export let onDownload: (key: string) => void = () => {};
export let isRoot: boolean = false;
function handleToggle(path: string) {
onToggle(path);
}
function handleDelete(key: string, isFolder: boolean) {
console.log('FileBrowser handleDelete called:', { key, isFolder });
onDelete(key, isFolder);
}
function handleDownload(key: string) {
onDownload(key);
}
function handleFolderClick(event: Event, path: string) {
event.stopPropagation();
handleToggle(path);
}
function handleFolderDelete(event: Event, path: string) {
event.stopPropagation();
console.log('FileBrowser handleFolderDelete called:', { path });
handleDelete(path, true);
}
function formatSize(size: number) {
if (!size && size !== 0) return '';
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
function formatDate(date: string | Date) {
if (!date) return '';
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleString();
}
</script>
<ul class="ml-2">
{#if isRoot}
<li class="flex items-center gap-2 py-1 pl-2 text-xs font-semibold text-muted-foreground select-none border-b border-border mb-1">
<span class="flex-1 min-w-0">Name</span>
<span class="w-24 text-right">Size</span>
<span class="w-40 text-right">Last Uploaded</span>
<span class="w-16"></span>
</li>
{/if}
{#each Object.entries(tree) as [name, node] (parentPath + '/' + name)}
{#if (node as any).isFile}
<li class="flex items-center justify-between py-1 pl-4">
<span>{name} <span class="text-xs text-muted-foreground">({(node as any).size} bytes)</span></span>
<Button size="sm" variant="destructive" on:click={() => handleDelete((node as any).key, false)}>Delete</Button>
<li class="flex items-center gap-2 py-1 pl-2 hover:bg-accent rounded group text-sm">
<span class="flex-1 min-w-0 flex items-center gap-1">
<FileIcon class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="truncate" title={name || ''}>{name || ''}</span>
</span>
<span class="w-24 text-xs text-muted-foreground text-right ml-2 whitespace-nowrap">{formatSize((node as any).size)}</span>
<span class="w-40 text-xs text-muted-foreground text-right ml-2 whitespace-nowrap">{formatDate((node as any).lastModified)}</span>
<span class="w-16 flex items-center justify-end gap-1">
<button class="p-1 hover:text-primary" title="Download" on:click={() => handleDownload((node as any).key)}><DownloadIcon class="w-4 h-4" /></button>
<button class="p-1 hover:text-destructive" title="Delete" on:click={() => handleDelete((node as any).key, false)}><TrashIcon class="w-4 h-4" /></button>
</span>
</li>
{:else}
<li>
<div class="flex items-center cursor-pointer" on:click={(e) => handleFolderClick(e, parentPath ? `${parentPath}/${name}` : name)}>
<span class="mr-1">{expanded.has(parentPath ? `${parentPath}/${name}` : name) ? '▼' : '▶'}</span>
<span class="font-semibold">{name}</span>
<Button size="sm" variant="destructive" class="ml-2" on:click={(e) => handleFolderDelete(e, (parentPath ? `${parentPath}/${name}` : name))}>Delete</Button>
<div class="flex items-center gap-1 px-1 py-1 hover:bg-accent rounded cursor-pointer text-sm" >
<span class="flex-1 min-w-0 flex items-center gap-1" on:click={(e) => handleFolderClick(e, parentPath ? `${parentPath}/${name}` : name)}>
<span class="flex-shrink-0 w-5 h-5 flex items-center justify-center">
{#if expanded.has(parentPath ? `${parentPath}/${name}` : name)}
<FolderOpenIcon class="w-4 h-4 text-muted-foreground" />
{:else}
<FolderIcon class="w-4 h-4 text-muted-foreground" />
{/if}
</span>
<span class="font-semibold truncate" title={name || ''}>{name || ''}</span>
</span>
<span class="w-24"></span>
<span class="w-40"></span>
<span class="w-16 flex items-center justify-end">
<button class="p-1 hover:text-destructive" title="Delete folder" on:click={(e) => handleFolderDelete(e, (parentPath ? `${parentPath}/${name}` : name))}><TrashIcon class="w-4 h-4" /></button>
</span>
</div>
{#if expanded.has(parentPath ? `${parentPath}/${name}` : name)}
<svelte:self
@@ -48,6 +94,8 @@
{expanded}
{onToggle}
{onDelete}
{onDownload}
isRoot={false}
/>
{/if}
</li>

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { onMount } from 'svelte';
import FileBrowser from './FileBrowser.svelte';
export let experimentId: string;
let files: any[] = [];
let uploading = false;
let uploadError = '';
async function fetchFiles() {
if (!experimentId) return;
const res = await fetch(`/api/experiment/${experimentId}/files`);
if (res.ok) {
const data = await res.json();
files = data.files;
}
}
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);
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) {
let relativeKey = key;
const expFilesIdx = key.indexOf('/experiment_files/');
if (expFilesIdx !== -1) {
relativeKey = key.substring(expFilesIdx + '/experiment_files/'.length);
} else {
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();
}
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) {
node[part] = { ...file, isFile: true };
} else {
node[part] = node[part] || { children: {}, isFile: false };
node = node[part].children;
}
}
}
return root;
}
function downloadFile(key: string) {
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<string>();
function toggleFolder(path: string) {
if (expanded.has(path)) expanded.delete(path);
else expanded.add(path);
expanded = new Set(expanded);
}
async function deleteFileOrFolder(key: string, isFolder: boolean) {
if (isFolder) {
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);
}
}
onMount(async () => {
await fetchFiles();
});
</script>
<Card class="w-full">
<CardHeader>
<CardTitle>Experiment Files</CardTitle>
</CardHeader>
<CardContent>
<div class="mt-8">
<div class="flex justify-end mb-4">
<Button
type="button"
class="px-4 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90 transition font-medium"
on:click={() => document.getElementById('file-input')?.click()}
>
Upload Files or Folder
</Button>
<input
id="file-input"
type="file"
multiple
webkitdirectory
class="hidden"
on:change={handleFileInput}
/>
</div>
{#if uploading}
<div class="text-blue-600 text-sm mb-2">Uploading...</div>
{/if}
{#if uploadError}
<div class="text-red-500 text-sm mb-2">{uploadError}</div>
{/if}
<ul class="divide-y divide-border">
<FileBrowser
tree={buildFileTree(files)}
parentPath=""
{expanded}
onToggle={toggleFolder}
onDelete={deleteFileOrFolder}
onDownload={downloadFile}
isRoot={true}
/>
{#if files.length === 0}
<li class="text-muted-foreground text-sm py-2">No files uploaded yet.</li>
{/if}
</ul>
</div>
</CardContent>
</Card>

View File

@@ -0,0 +1,19 @@
<!-- This layout bypasses the main app layout for multiplayer experiments -->
<slot />
<style>
/* Ensure multiplayer experiments are completely full-screen */
:global(html), :global(body) {
margin: 0;
padding: 0;
width: 100%;
min-height: 100vh;
overflow: auto;
background: white;
}
/* Reset any inherited styles */
:global(*) {
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,652 @@
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 } = params;
// Check if the experiment exists
const experiment = await db.query.experiment.findFirst({
where: eq(schema.experiment.id, experimentId)
});
if (!experiment) {
throw error(404, 'Experiment not found');
}
// For multiplayer, we always serve index.html with WebSocket integration
const path = 'index.html';
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
});
}
}
// Files are stored in the user's experiment_files directory
const createdBy = experiment.createdBy;
const s3Prefix = `${createdBy}/${experimentId}/experiment_files/`;
const key = `${s3Prefix}${path}`;
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 body = await stream.transformToString();
// Inject multiplayer WebSocket integration into the HTML
const multiplayerScript = `
<script src="https://unpkg.com/colyseus.js@^0.16.0/dist/colyseus.js"></script>
<script>
// Multiplayer Colyseus Integration
let client = null;
let room = null;
let isConnected = false;
let overlayVisible = true;
let sessionStartTime = new Date();
let isLoading = true;
let participantId = null;
let roomStatus = 'connecting';
let queuePosition = 0;
let currentParticipants = 0;
let maxParticipants = 1;
// Connect to Colyseus server
async function connectMultiplayer() {
try {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const host = location.hostname;
const port = '8080'; // Colyseus server port
client = new Colyseus.Client(\`\${protocol}://\${host}:\${port}\`);
// Join or create room for this experiment
room = await client.joinOrCreate('experiment_room', {
experimentId: '${experimentId}',
userAgent: navigator.userAgent,
ipAddress: null // Will be set by server
});
isConnected = true;
roomStatus = 'connected';
updateOverlay();
console.log('Connected to Colyseus room:', room.roomId);
// Handle room state changes
room.onStateChange((state) => {
console.log('Room state updated:', state);
handleStateUpdate(state);
});
// Handle messages from server
room.onMessage('queued', (message) => {
handleQueued(message);
});
room.onMessage('admitted', (message) => {
handleAdmitted(message);
});
room.onMessage('joined', (message) => {
handleJoined(message);
});
room.onMessage('queue_update', (message) => {
handleQueueUpdate(message);
});
room.onMessage('experiment_started', (message) => {
handleExperimentStarted(message);
});
room.onMessage('experiment_action', (message) => {
handleExperimentAction(message);
});
room.onMessage('experiment_state_update', (message) => {
handleExperimentStateUpdate(message);
});
room.onError((code, message) => {
console.error('Room error:', code, message);
isConnected = false;
roomStatus = 'error';
updateOverlay();
});
room.onLeave((code) => {
console.log('Left room with code:', code);
isConnected = false;
roomStatus = 'disconnected';
updateOverlay();
});
} catch (error) {
console.error('Failed to connect to multiplayer server:', error);
isConnected = false;
roomStatus = 'error';
updateOverlay();
}
}
function handleStateUpdate(state) {
currentParticipants = state.currentParticipants;
maxParticipants = state.maxParticipants;
// Update participant info
if (state.participants && participantId) {
const participant = state.participants.get(participantId);
if (participant) {
console.log('Participant updated:', participant);
}
}
updateOverlay();
}
function handleQueued(message) {
roomStatus = 'queued';
queuePosition = message.position;
console.log('Added to queue:', message);
updateOverlay();
}
function handleAdmitted(message) {
roomStatus = 'admitted';
participantId = message.participantId;
currentParticipants = message.currentParticipants;
maxParticipants = message.maxParticipants;
console.log('Admitted to experiment:', message);
if (isLoading) {
isLoading = false;
hideLoadingScreen();
}
updateOverlay();
}
function handleJoined(message) {
roomStatus = 'joined';
participantId = message.participantId;
currentParticipants = message.currentParticipants;
maxParticipants = message.maxParticipants;
console.log('Joined experiment:', message);
if (isLoading) {
isLoading = false;
hideLoadingScreen();
}
updateOverlay();
}
function handleQueueUpdate(message) {
queuePosition = message.position;
console.log('Queue position updated:', message);
updateOverlay();
}
function handleExperimentStarted(message) {
roomStatus = 'running';
console.log('Experiment started:', message);
updateOverlay();
}
function handleExperimentAction(message) {
// Handle actions from other participants
console.log('Experiment action from participant:', message);
// This would be where you'd handle specific experiment interactions
}
function handleExperimentStateUpdate(message) {
// Handle experiment state updates
console.log('Experiment state updated:', message);
// This would be where you'd sync experiment state across participants
}
function hideLoadingScreen() {
const loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) {
loadingScreen.style.display = 'none';
}
}
function updateOverlay() {
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
const participantCount = document.getElementById('participant-count');
const queueInfo = document.getElementById('queue-info');
if (statusDot) {
statusDot.className = 'status-dot ' + (isConnected ? 'connected' : 'disconnected');
}
if (statusText) {
let statusMessage = 'Disconnected';
if (isConnected) {
switch (roomStatus) {
case 'connected':
statusMessage = 'Connected';
break;
case 'queued':
statusMessage = \`Queue #\${queuePosition}\`;
break;
case 'admitted':
case 'joined':
statusMessage = 'In Session';
break;
case 'running':
statusMessage = 'Running';
break;
default:
statusMessage = 'Connected';
}
}
statusText.textContent = statusMessage;
}
if (participantCount) {
participantCount.textContent = \`\${currentParticipants}/\${maxParticipants}\`;
}
if (queueInfo) {
if (roomStatus === 'queued') {
queueInfo.textContent = \`Position \${queuePosition} in queue\`;
queueInfo.style.display = 'block';
} else {
queueInfo.style.display = 'none';
}
}
}
function toggleOverlay() {
const overlay = document.getElementById('multiplayer-overlay');
const minimized = document.getElementById('minimized-overlay');
if (overlay && minimized) {
overlayVisible = !overlayVisible;
overlay.style.display = overlayVisible ? 'block' : 'none';
minimized.style.display = overlayVisible ? 'none' : 'block';
}
}
// Send experiment action to other participants
function sendExperimentAction(action, data) {
if (room && roomStatus === 'running') {
room.send('experiment_action', { action, data });
}
}
// Send experiment state update
function sendExperimentState(state) {
if (room && roomStatus === 'running') {
room.send('experiment_state', { state });
}
}
// Mark participant as ready
function markReady() {
if (room && participantId) {
room.send('ready', { participantId });
}
}
// Handle user interactions
function handleInteraction(event) {
if (event instanceof KeyboardEvent && event.key === 'Tab') {
event.preventDefault();
toggleOverlay();
return;
}
// For now, just log interactions - experiment-specific handling would go here
if (roomStatus === 'running') {
console.log('User interaction:', event.type, event);
}
}
// Initialize multiplayer when DOM is ready
function initMultiplayer() {
// Add event listeners
document.addEventListener('keydown', handleInteraction, true);
// Connect to Colyseus
connectMultiplayer();
}
// Start multiplayer when page loads
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initMultiplayer);
} else {
initMultiplayer();
}
// Make functions available globally for experiment use
window.multiplayerAPI = {
sendAction: sendExperimentAction,
sendState: sendExperimentState,
markReady: markReady,
getStatus: () => ({ roomStatus, participantId, currentParticipants, maxParticipants })
};
</script>
`;
const multiplayerCSS = `
<style>
/* Multiplayer overlay styles */
#multiplayer-overlay {
position: fixed;
top: 16px;
right: 16px;
z-index: 9999;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 12px 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.1);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
max-width: 250px;
}
#minimized-overlay {
position: fixed;
top: 16px;
right: 16px;
z-index: 9999;
background: rgba(0, 0, 0, 0.6);
color: white;
padding: 8px 12px;
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
display: none;
}
.overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.connected {
background: #10b981;
}
.status-dot.disconnected {
background: #ef4444;
}
.close-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 16px;
padding: 0;
margin-left: 12px;
}
.close-btn:hover {
color: white;
}
.overlay-info {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
line-height: 1.4;
}
.overlay-separator {
margin: 8px 0;
border-top: 1px solid rgba(255, 255, 255, 0.2);
padding-top: 8px;
}
#loading-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #f0f9ff 0%, #e0e7ff 100%);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.loading-content {
text-align: center;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 3px solid #e2e8f0;
border-top: 3px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
color: #374151;
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
.loading-subtext {
color: #6b7280;
font-size: 14px;
margin-bottom: 16px;
}
.loading-hint {
color: #9ca3af;
font-size: 12px;
}
/* Ensure overlay elements don't interfere with experiment */
#multiplayer-overlay,
#minimized-overlay {
pointer-events: auto;
}
#multiplayer-overlay *,
#minimized-overlay * {
pointer-events: auto;
}
/* Full screen styles */
body {
margin: 0;
padding: 0;
width: 100%;
min-height: 100vh;
background: white;
}
html {
margin: 0;
padding: 0;
width: 100%;
min-height: 100vh;
}
</style>
`;
const multiplayerOverlay = `
<!-- Loading screen -->
<div id="loading-screen">
<div class="loading-content">
<div class="loading-spinner"></div>
<div class="loading-text">Loading experiment...</div>
<div class="loading-subtext">Connecting to multiplayer session</div>
<div class="loading-hint">Your experiment will open in full screen</div>
</div>
</div>
<!-- Multiplayer overlay -->
<div id="multiplayer-overlay">
<div class="overlay-header">
<div class="status-indicator">
<div id="status-dot" class="status-dot disconnected"></div>
<span id="status-text">Disconnected</span>
</div>
<button class="close-btn" onclick="toggleOverlay()">×</button>
</div>
<div class="overlay-info">
<div>🎮 Multiplayer Session</div>
<div>👥 Participants: <span id="participant-count">0/1</span></div>
<div>🕐 Started: <span id="session-time"></span></div>
<div>📋 ID: ${experimentId.slice(0, 8)}...</div>
<div id="queue-info" style="display: none; color: #fbbf24; margin-top: 4px;">
Position in queue
</div>
<div class="overlay-separator">
<div style="color: rgba(255, 255, 255, 0.6);">Press Tab to toggle this overlay</div>
</div>
</div>
</div>
<!-- Minimized overlay -->
<div id="minimized-overlay" onclick="toggleOverlay()">
<div style="display: flex; align-items: center; gap: 8px;">
<div id="minimized-status-dot" class="status-dot disconnected"></div>
<span>MP</span>
</div>
</div>
<script>
// Update session time
document.getElementById('session-time').textContent = new Date().toLocaleTimeString();
</script>
`;
// Inject multiplayer functionality into the HTML
const injectedBody = body
.replace(/<head[^>]*>/, `$&<base href="/public/multiplayer/run/${experimentId}/">${multiplayerCSS}`)
.replace(/<\/body>/, `${multiplayerOverlay}${multiplayerScript}</body>`);
return new Response(injectedBody, {
headers: {
'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(injectedBody, 'utf8').toString()
}
});
} catch (e: any) {
if (e.name === 'NoSuchKey') {
throw error(404, 'Not found');
}
throw error(500, 'Internal server error');
}
}

View File

@@ -0,0 +1,276 @@
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<string, string> = {
'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
});
}
}
// Files are stored in the user's experiment_files directory
const createdBy = experiment.createdBy;
const s3Prefix = `${createdBy}/${experimentId}/experiment_files/`;
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: s3Prefix
});
const listResponse = await s3.send(listCommand);
// Create resource manifest for PsychoJS
const resources = (listResponse.Contents || [])
.filter(obj => obj.Key && obj.Key !== `${s3Prefix}index.html`)
.map(obj => {
const relativePath = obj.Key!.replace(s3Prefix, '');
return {
name: relativePath,
path: relativePath
};
});
// Create the resource injection script that runs after PsychoJS loads
const resourceInjectionScript = `
<script>
// Wait for PsychoJS to be available and inject resources
function injectPsychoJSResources() {
const resources = ${JSON.stringify(resources)};
// Check if PsychoJS and its components are available
if (typeof psychoJS !== 'undefined' && psychoJS.serverManager) {
// Add resources to the server manager's resource list
resources.forEach(resource => {
psychoJS.serverManager._resources.set(resource.name, {
name: resource.name,
path: resource.path,
status: psychoJS.serverManager.constructor.ResourceStatus.NOT_DOWNLOADED
});
});
} else {
// PsychoJS not ready yet, try again in 100ms
setTimeout(injectPsychoJSResources, 100);
}
}
// Start trying to inject resources when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectPsychoJSResources);
} else {
injectPsychoJSResources();
}
</script>
`;
const injectedBody = body
.replace(/<head[^>]*>/, `$&<base href="${basePath}">`)
.replace(/<\/body>/, `${resourceInjectionScript}<script>console.log('injection')</script></body>`);
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}`);
}
}
}

View File

@@ -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 = [
@@ -31,6 +31,15 @@ const vendorFileRules = [
export async function GET({ params, cookies, getClientAddress, request }) {
const { experimentId, path } = params;
// Check if the experiment exists
const experiment = await db.query.experiment.findFirst({
where: eq(schema.experiment.id, experimentId)
});
if (!experiment) {
throw error(404, 'Experiment not found');
}
// Map of requested CSS files to their location in the static directory.
const staticCssMap: Record<string, string> = {
'lib/vendors/survey.widgets.css': 'static/lib/psychoJS/surveyJS/survey.widgets.css',
@@ -99,22 +108,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 +136,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,
@@ -145,7 +160,9 @@ export async function GET({ params, cookies, getClientAddress, request }) {
}
}
const s3Prefix = `experiments/${experimentId}/`;
// Files are stored in the user's experiment_files directory
const createdBy = experiment.createdBy;
const s3Prefix = `${createdBy}/${experimentId}/experiment_files/`;
const filePath = path === '' ? 'index.html' : path;
const key = `${s3Prefix}${filePath}`;
@@ -174,20 +191,22 @@ export async function GET({ params, cookies, getClientAddress, request }) {
// 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/run/${experimentId}/`;
// Use the request URL to determine if this is multiplayer or single player
const isMultiplayer = request.url.includes('/multiplayer/');
const basePath = isMultiplayer ? `/public/multiplayer/run/${experimentId}/` : `/public/run/${experimentId}/`;
// Get all files in the experiment directory to create a resource manifest
const listCommand = new ListObjectsV2Command({
Bucket: S3_BUCKET,
Prefix: `experiments/${experimentId}/`
Prefix: s3Prefix
});
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`)
.filter(obj => obj.Key && obj.Key !== `${s3Prefix}index.html`)
.map(obj => {
const relativePath = obj.Key!.replace(`experiments/${experimentId}/`, '');
const relativePath = obj.Key!.replace(s3Prefix, '');
return {
name: relativePath,
path: relativePath

View File

@@ -0,0 +1,10 @@
<script lang="ts">
// Dummy settings page
</script>
<h1 class="text-2xl font-bold mb-4">Settings</h1>
<div class="space-y-4">
<div class="p-4 bg-muted rounded">Option 1: [dummy toggle]</div>
<div class="p-4 bg-muted rounded">Option 2: [dummy select]</div>
<div class="p-4 bg-muted rounded">More settings coming soon...</div>
</div>

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
let email = '';
let currentPassword = '';
let newPassword = '';
let confirmPassword = '';
// Use $page as a reactive value
$: fullName = $page.data?.user?.username || '';
let storageUsed = 0;
const MAX_STORAGE = 1024 * 1024 * 1024; // 1GB in bytes
let loadingStorage = true;
onMount(async () => {
loadingStorage = true;
try {
const res = await fetch('/api/user-storage');
if (res.ok) {
const data = await res.json();
storageUsed = data.usage;
}
} finally {
loadingStorage = false;
}
});
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function handleEmailChange(e: Event) {
e.preventDefault();
alert('Email change submitted (dummy)');
}
function handlePasswordChange(e: Event) {
e.preventDefault();
if (newPassword !== confirmPassword) {
alert('Passwords do not match!');
return;
}
alert('Password change submitted (dummy)');
}
</script>
<h1 class="text-2xl font-bold mb-4">Profile</h1>
<!-- Storage Usage Bar -->
<div class="mb-8 p-4 bg-muted rounded">
<div class="font-medium mb-2">Storage Usage</div>
{#if loadingStorage}
<div>Loading...</div>
{:else}
<div class="flex items-center gap-2 mb-1">
<div class="flex-1 h-4 bg-gray-200 rounded overflow-hidden">
<div class="h-4 bg-primary" style="width: {Math.min(100, (storageUsed / MAX_STORAGE) * 100)}%"></div>
</div>
<div class="text-sm text-muted-foreground whitespace-nowrap">{formatBytes(storageUsed)} / 1 GB</div>
</div>
{#if storageUsed > MAX_STORAGE}
<div class="text-red-600 text-xs mt-1">You have exceeded your storage limit!</div>
{/if}
{/if}
</div>
<div class="mb-8 p-4 bg-muted rounded">
<div class="font-medium">Full Name</div>
<div class="text-lg">{fullName}</div>
</div>
<form class="space-y-4 mb-8" on:submit|preventDefault={handleEmailChange}>
<div>
<label class="block mb-1 font-medium" for="email">Email Address</label>
<input id="email" type="email" bind:value={email} class="border rounded px-3 py-2 w-full" required />
</div>
<button type="submit" class="bg-primary text-primary-foreground rounded px-4 py-2">Change Email</button>
</form>
<form class="space-y-4" on:submit|preventDefault={handlePasswordChange}>
<div>
<label class="block mb-1 font-medium" for="currentPassword">Current Password</label>
<input id="currentPassword" type="password" bind:value={currentPassword} class="border rounded px-3 py-2 w-full" required />
</div>
<div>
<label class="block mb-1 font-medium" for="newPassword">New Password</label>
<input id="newPassword" type="password" bind:value={newPassword} class="border rounded px-3 py-2 w-full" required />
</div>
<div>
<label class="block mb-1 font-medium" for="confirmPassword">Confirm New Password</label>
<input id="confirmPassword" type="password" bind:value={confirmPassword} class="border rounded px-3 py-2 w-full" required />
</div>
<button type="submit" class="bg-primary text-primary-foreground rounded px-4 py-2">Change Password</button>
</form>

View File

@@ -0,0 +1,10 @@
<script lang="ts">
// Dummy subscription page
</script>
<h1 class="text-2xl font-bold mb-4">Subscription</h1>
<div class="space-y-4">
<div class="p-4 bg-muted rounded">Current Plan: [dummy plan]</div>
<div class="p-4 bg-muted rounded">Renewal Date: [dummy date]</div>
<div class="p-4 bg-muted rounded">Upgrade options coming soon...</div>
</div>

229
test-multiplayer.html Normal file
View File

@@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multiplayer Test</title>
<script src="https://unpkg.com/colyseus.js@^0.16.0/dist/colyseus.js"></script>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.status {
padding: 10px;
margin: 10px 0;
border-radius: 5px;
}
.connected {
background-color: #d4edda;
color: #155724;
}
.disconnected {
background-color: #f8d7da;
color: #721c24;
}
.queued {
background-color: #fff3cd;
color: #856404;
}
.log {
background-color: #f8f9fa;
padding: 10px;
margin: 10px 0;
border-radius: 5px;
font-family: monospace;
white-space: pre-wrap;
max-height: 400px;
overflow-y: auto;
}
button {
padding: 10px 20px;
margin: 5px;
border: none;
border-radius: 5px;
cursor: pointer;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
</style>
</head>
<body>
<h1>Multiplayer Test</h1>
<div id="status" class="status disconnected">
Status: Disconnected
</div>
<div>
<button id="connectBtn" class="btn-primary" onclick="connectToRoom()">Connect to Room</button>
<button id="disconnectBtn" class="btn-secondary" onclick="disconnectFromRoom()" disabled>Disconnect</button>
<button id="readyBtn" class="btn-primary" onclick="markReady()" disabled>Mark Ready</button>
</div>
<div>
<h3>Room Info</h3>
<p>Room ID: <span id="roomId">-</span></p>
<p>Participants: <span id="participants">0/1</span></p>
<p>Queue Position: <span id="queuePosition">-</span></p>
<p>Status: <span id="roomStatus">-</span></p>
</div>
<div>
<h3>Log</h3>
<div id="log" class="log"></div>
</div>
<script>
let client = null;
let room = null;
let isConnected = false;
const statusDiv = document.getElementById('status');
const connectBtn = document.getElementById('connectBtn');
const disconnectBtn = document.getElementById('disconnectBtn');
const readyBtn = document.getElementById('readyBtn');
const roomIdSpan = document.getElementById('roomId');
const participantsSpan = document.getElementById('participants');
const queuePositionSpan = document.getElementById('queuePosition');
const roomStatusSpan = document.getElementById('roomStatus');
const logDiv = document.getElementById('log');
function log(message) {
const timestamp = new Date().toLocaleTimeString();
const logMessage = `[${timestamp}] ${message}`;
logDiv.textContent += logMessage + '\n';
logDiv.scrollTop = logDiv.scrollHeight;
console.log(logMessage);
}
function updateStatus(status, className) {
statusDiv.textContent = `Status: ${status}`;
statusDiv.className = `status ${className}`;
}
function updateUI() {
connectBtn.disabled = isConnected;
disconnectBtn.disabled = !isConnected;
readyBtn.disabled = !isConnected;
}
async function connectToRoom() {
try {
log('Connecting to Colyseus server...');
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const host = location.hostname;
const port = '8080';
client = new Colyseus.Client(`${protocol}://${host}:${port}`);
log('Joining experiment room...');
room = await client.joinOrCreate('experiment_room', {
experimentId: 'test-experiment-123',
userAgent: navigator.userAgent,
ipAddress: null
});
isConnected = true;
updateStatus('Connected', 'connected');
updateUI();
roomIdSpan.textContent = room.roomId;
log(`Connected to room: ${room.roomId}`);
// Handle room state changes
room.onStateChange((state) => {
log(`Room state updated: ${JSON.stringify(state, null, 2)}`);
participantsSpan.textContent = `${state.currentParticipants}/${state.maxParticipants}`;
roomStatusSpan.textContent = state.status;
});
// Handle messages
room.onMessage('queued', (message) => {
log(`Queued: ${JSON.stringify(message)}`);
updateStatus(`Queued (Position: ${message.position})`, 'queued');
queuePositionSpan.textContent = message.position;
});
room.onMessage('admitted', (message) => {
log(`Admitted: ${JSON.stringify(message)}`);
updateStatus('Admitted', 'connected');
queuePositionSpan.textContent = '-';
});
room.onMessage('joined', (message) => {
log(`Joined: ${JSON.stringify(message)}`);
updateStatus('Joined', 'connected');
queuePositionSpan.textContent = '-';
});
room.onMessage('queue_update', (message) => {
log(`Queue update: ${JSON.stringify(message)}`);
queuePositionSpan.textContent = message.position;
});
room.onMessage('experiment_started', (message) => {
log(`Experiment started: ${JSON.stringify(message)}`);
updateStatus('Experiment Running', 'connected');
});
room.onError((code, message) => {
log(`Room error: ${code} - ${message}`);
updateStatus('Error', 'disconnected');
});
room.onLeave((code) => {
log(`Left room with code: ${code}`);
isConnected = false;
updateStatus('Disconnected', 'disconnected');
updateUI();
});
} catch (error) {
log(`Connection error: ${error.message}`);
updateStatus('Connection Failed', 'disconnected');
isConnected = false;
updateUI();
}
}
function disconnectFromRoom() {
if (room) {
log('Disconnecting from room...');
room.leave();
room = null;
client = null;
isConnected = false;
updateStatus('Disconnected', 'disconnected');
updateUI();
// Reset UI
roomIdSpan.textContent = '-';
participantsSpan.textContent = '0/1';
queuePositionSpan.textContent = '-';
roomStatusSpan.textContent = '-';
}
}
function markReady() {
if (room) {
log('Marking as ready...');
room.send('ready', { participantId: room.sessionId });
}
}
// Initial UI update
updateUI();
log('Multiplayer test page loaded');
</script>
</body>
</html>

View File

@@ -9,7 +9,9 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
"moduleResolution": "bundler",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files

View File

@@ -5,6 +5,11 @@ import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit(), devtoolsJson()],
server: {
host: '0.0.0.0', // Listen on all interfaces so Docker containers can access
port: 5173,
allowedHosts: ['host.docker.internal'] // Allow Docker containers to access via host.docker.internal
},
test: {
projects: [
{