Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ data/
# Temp
*.log
.claude/
.worktrees/
CLAUDE.md
.serena/
.playwright-mcp/
Expand Down
233 changes: 233 additions & 0 deletions docs/plans/2026-02-22-realtime-websocket-editor-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
# Realtime WebSocket Editor Design

**Date:** 2026-02-22
**Status:** Approved
**Scope:** HyperPerms plugin, HyperPermsWeb editor, new CF Workers relay

## Problem

The web editor at hyperperms.com uses a polling/REST model: the plugin POSTs session
data to the API, the user edits in the browser, then runs `/hp apply <session>` to pull
changes back. This is slow, manual, and one-directional.

## Goal

Two-way realtime sync between the browser editor and the Hytale plugin via WebSocket,
so edits flow in both directions without manual commands.

## Decisions

| Decision | Choice | Rationale |
|---|---|---|
| Direction | Two-way full sync | Editor pushes to server, server pushes to editor |
| Transport | WebSocket via Cloudflare Durable Objects | Already on CF Workers, no extra vendor, ~$5/month |
| Relay model | API-side (CF DO) | Both plugin and browser connect as WS clients to CF |
| Default apply mode | Batch with confirm | Safety: changes accumulate, user clicks Apply |
| Live mode | Toggle in editor UI | Opt-in immediate apply for power users |
| Undo | Server-side undo history (last 20 ops) | Reversible batch applies |
| Message format | Incremental diffs (operations) | Small payloads, natural undo, efficient |
| Conflict handling | Auto-merge non-conflicting, UI for conflicts | Server changes to different entities merge silently |

## Architecture

```
Browser (Next.js) CF Durable Object Hytale Plugin (Java)
| (1 DO per session) |
|--- WS connect -------->| |
| |<-------- WS connect ---------------|
| | |
|-- editor.change ------->| (stores op in pending list) |
| | |
|-- batch.apply --------->| |
| |--- batch.apply ------------------->|
| | [applies to live server]
| |<--- apply.result ------------------|
|<-- apply.result --------| |
| | |
| |<--- server.change -----------------|
|<-- server.change -------| [in-game command ran]
```

### Session Lifecycle

1. Player runs `/hp editor` in-game
2. Plugin creates session via existing REST API (POST /api/session/create)
3. API returns session ID + editor URL (unchanged)
4. Plugin opens WebSocket to `wss://ws.hyperperms.com/session/<id>` as `plugin` role
5. Player opens editor in browser, editor opens WebSocket to same URL as `editor` role
6. DO has both connections -- relay begins
7. On disconnect/timeout, DO hibernates (cost-efficient)
8. Session expires after 24h (same as current TTL)

### Why CF Durable Objects

- CF Workers already in use (no new vendor)
- DOs can hold persistent WebSocket connections (unlike Vercel serverless)
- Built-in hibernation API saves costs when idle
- Native WebSocket support with `acceptWebSocket()` API
- Cost: ~$0.15/M requests + $0.50/GB-month -- effectively free at our volume
- Stays within Cloudflare ecosystem

## Message Protocol

### Operation Types

All messages are JSON with a `type` field. Operations are the atomic unit of change.

```typescript
// === Editor -> DO -> Plugin ===

// Individual changes (accumulated in batch mode, applied immediately in live mode)
{ type: "op", op: "permission.add", target: "group:admin", node: "server.kick", value: true }
{ type: "op", op: "permission.remove", target: "group:admin", node: "server.kick" }
{ type: "op", op: "permission.set", target: "user:<uuid>", node: "chat.color", value: false }
{ type: "op", op: "group.create", name: "moderator", weight: 50, parents: ["default"] }
{ type: "op", op: "group.delete", name: "moderator" }
{ type: "op", op: "group.setMeta", target: "admin", key: "prefix", value: "&c[Admin] " }
{ type: "op", op: "group.setWeight", target: "admin", weight: 100 }
{ type: "op", op: "group.addParent", target: "moderator", parent: "default" }
{ type: "op", op: "group.removeParent", target: "moderator", parent: "default" }
{ type: "op", op: "user.addGroup", target: "<uuid>", group: "admin" }
{ type: "op", op: "user.removeGroup", target: "<uuid>", group: "admin" }
{ type: "op", op: "track.create", name: "staff", groups: ["default", "helper", "mod", "admin"] }
{ type: "op", op: "track.delete", name: "staff" }
{ type: "op", op: "track.addGroup", target: "staff", group: "moderator", position: 2 }
{ type: "op", op: "track.removeGroup", target: "staff", group: "moderator" }

// Batch apply (batch mode only)
{ type: "batch.apply" }

// Mode switch
{ type: "session.mode", mode: "live" | "batch" }

// === Plugin -> DO -> Editor ===

// Server-side change (in-game command, API, another plugin)
{ type: "server.change", op: "permission.add", target: "group:admin", node: "fly.use",
value: true, source: "console", timestamp: 1708617600000 }

// Apply result
{ type: "apply.result", success: true, applied: 5, failed: 0,
errors: [], undoId: "abc123" }

// Undo result
{ type: "undo.result", success: true, undoId: "abc123", reverted: 5 }

// === Control messages (both directions) ===

{ type: "session.sync", data: { groups: [...], users: [...], tracks: [...] } }
{ type: "session.ping" }
{ type: "session.pong" }
{ type: "session.error", code: "CONFLICT", message: "...", details: {...} }
```

### Operation Inversion (for undo)

Each operation has a natural inverse stored in the undo history:

| Operation | Inverse |
|---|---|
| `permission.add` | `permission.remove` (same target/node) |
| `permission.remove` | `permission.add` (with original value) |
| `group.create` | `group.delete` |
| `group.delete` | `group.create` (with full group data snapshot) |
| `group.setWeight` | `group.setWeight` (with previous weight) |
| `user.addGroup` | `user.removeGroup` |
| `user.removeGroup` | `user.addGroup` |
| `track.addGroup` | `track.removeGroup` |

## Conflict Detection

### Non-conflicting (auto-merge)

Server change to entity A while editor has pending changes to entity B:
- Apply server change to editor state silently
- Show subtle indicator that server state updated (e.g. pulse animation on the changed entity)

### Conflicting

Server change to entity A while editor has pending changes to entity A:
- Mark the entity with a conflict indicator in the UI
- Show conflict banner: "Server changed [group: admin] -- your local changes may conflict"
- Options: "Keep mine", "Accept server", "View diff"
- Conflict state clears when user resolves

### Detection Logic

Conflict = same `target` value in both a pending editor op and an incoming server change.
Tracked per-entity, not per-field (simple and predictable).

## Undo History

- Stored in the Durable Object's transactional storage
- Last 20 batch applies, each with its inverse operations
- Each undo entry: `{ undoId, timestamp, ops: Operation[], inverseOps: Operation[] }`
- Undo request: `{ type: "undo", undoId: "abc123" }` -> sends inverse ops to plugin
- History clears on session expiry

## Three Codebases

### 1. CF Workers (new Durable Object)

**Location:** New worker in existing CF project or standalone
**Responsibilities:**
- Accept WebSocket connections from editor and plugin
- Route messages between them based on type
- Store pending operations (batch mode)
- Maintain undo history
- Handle hibernation for cost efficiency
- Session authentication (validate session ID against Upstash Redis)

**Key files:**
- `src/session-do.ts` -- Durable Object class with WebSocket handlers
- `src/index.ts` -- Worker entry point, routes `/session/:id` to DO
- `wrangler.toml` -- DO binding configuration

### 2. HyperPermsWeb (Next.js editor)

**Changes:**
- New WebSocket hook: `useRealtimeSession(sessionId)` -- manages WS connection, reconnection, message handling
- Editor state: track pending operations, conflict state
- UI: "Live" toggle switch, Apply button (batch mode), conflict banners, undo button
- Connection status indicator (connected/reconnecting/disconnected)
- Replace current `PUT /api/session` save flow with WebSocket ops

**Key files:**
- `src/hooks/useRealtimeSession.ts` -- WebSocket connection + state management
- `src/components/editor/RealtimeStatus.tsx` -- Connection indicator
- `src/components/editor/ConflictBanner.tsx` -- Conflict resolution UI
- `src/components/editor/LiveModeToggle.tsx` -- Live/batch toggle
- Modifications to existing editor components to dispatch ops instead of direct state mutation

### 3. HyperPerms (Java plugin)

**Changes:**
- New `WebSocketClient` class using Java 11+ `HttpClient` WebSocket API
- Change listener: hook into existing EventBus to detect in-game permission changes
- Apply handler: receive ops from editor, apply them through existing manager layer
- Undo support: store pre-apply snapshots for revert
- Auto-reconnect on connection loss
- New config options: `webEditor.realtimeEnabled`, `webEditor.wsUrl`

**Key files:**
- `src/main/java/com/hyperperms/web/RealtimeClient.java` -- WebSocket client
- `src/main/java/com/hyperperms/web/RealtimeMessageHandler.java` -- Message routing
- `src/main/java/com/hyperperms/web/OperationApplier.java` -- Applies ops to live state
- `src/main/java/com/hyperperms/web/ChangeListener.java` -- Detects in-game changes, sends to WS
- Modifications to `WebEditorService.java` -- Start WS after session creation

## Security

- Session ID serves as auth token (same as current model)
- DO validates session exists in Redis before accepting WS upgrade
- Plugin identifies as `role: plugin` on connect, editor as `role: editor`
- Only one plugin connection per session (reject duplicates)
- Rate limiting on operations (prevent abuse from editor)
- All traffic over WSS (TLS)

## Migration / Backwards Compatibility

- Existing REST flow continues to work (no breaking changes)
- WebSocket is additive -- if WS connection fails, editor falls back to REST save/load
- Plugin config: `webEditor.realtimeEnabled: true` (default true for new installs)
- `/hp apply` command still works as fallback
7 changes: 6 additions & 1 deletion src/main/java/com/hyperperms/HyperPerms.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -217,7 +217,7 @@ public void enable() {

// Initialize managers with event bus
groupManager = new GroupManagerImpl(storage, cacheInvalidator, eventBus);
trackManager = new TrackManagerImpl(storage);
trackManager = new TrackManagerImpl(storage, eventBus);
userManager = new UserManagerImpl(storage, cache, eventBus, config.getDefaultGroup());

// Load data
Expand DownExpand Up@@ -428,6 +428,11 @@ public void disable() {
placeholderApiIntegration.unregister();
}

// Disconnect WebSocket before shutting down scheduler
if (webEditorService != null) {
webEditorService.disconnectWebSocket();
}

// Stop scheduled tasks FIRST to prevent new storage executor submissions
if (expiryTask != null) {
expiryTask.cancel(true);
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/com/hyperperms/api/events/HyperPermsEvent.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,21 @@ enum EventType {
*/
DATA_RELOAD,

/**
* Fired when a track is created.
*/
TRACK_CREATE,

/**
* Fired when a track is deleted.
*/
TRACK_DELETE,

/**
* Fired when a track is modified.
*/
TRACK_MODIFY,

/**
* Fired when a user is promoted along a track.
*/
Expand Down
112 changes: 112 additions & 0 deletions src/main/java/com/hyperperms/api/events/TrackCreateEvent.java
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
package com.hyperperms.api.events;

import com.hyperperms.model.Track;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Event fired when a track is created.
* <p>
* This event can be cancelled to prevent the track from being created.
* The PRE state fires before creation, the POST state fires after.
*/
public final class TrackCreateEvent implements HyperPermsEvent, Cancellable {

/**
* The state of the track creation event.
*/
public enum State {
/**
* Before the track is created. The event can be cancelled at this point.
*/
PRE,

/**
* After the track has been created. Cancellation has no effect.
*/
POST
}

private final String trackName;
private final Track track;
private final State state;
private boolean cancelled;

/**
* Creates a PRE event for track creation.
*
* @param trackName the name of the track being created
*/
public TrackCreateEvent(@NotNull String trackName) {
this.trackName = trackName;
this.track = null;
this.state = State.PRE;
this.cancelled = false;
}

/**
* Creates a POST event for track creation.
*
* @param track the created track
*/
public TrackCreateEvent(@NotNull Track track) {
this.trackName = track.getName();
this.track = track;
this.state = State.POST;
this.cancelled = false;
}

@Override
public EventType getType() {
return EventType.TRACK_CREATE;
}

/**
* Gets the name of the track being created.
*
* @return the track name
*/
@NotNull
public String getTrackName() {
return trackName;
}

/**
* Gets the created track.
* <p>
* This is only available in the POST state. Returns null in PRE state.
*
* @return the track, or null if PRE state
*/
@Nullable
public Track getTrack() {
return track;
}

/**
* Gets the state of this event.
*
* @return the state
*/
@NotNull
public State getState() {
return state;
}

@Override
public boolean isCancelled() {
return cancelled;
}

@Override
public void setCancelled(boolean cancelled) {
if (state == State.PRE) {
this.cancelled = cancelled;
}
}

@Override
public String toString() {
return "TrackCreateEvent{trackName='" + trackName + "', state=" + state + ", cancelled=" + cancelled + "}";
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ data/
# Temp
*.log
.claude/
.worktrees/
CLAUDE.md
.serena/
.playwright-mcp/
Expand Down
233 changes: 233 additions & 0 deletions docs/plans/2026-02-22-realtime-websocket-editor-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
# Realtime WebSocket Editor Design

**Date:** 2026-02-22
**Status:** Approved
**Scope:** HyperPerms plugin, HyperPermsWeb editor, new CF Workers relay

## Problem

The web editor at hyperperms.com uses a polling/REST model: the plugin POSTs session
data to the API, the user edits in the browser, then runs `/hp apply <session>` to pull
changes back. This is slow, manual, and one-directional.

## Goal

Two-way realtime sync between the browser editor and the Hytale plugin via WebSocket,
so edits flow in both directions without manual commands.

## Decisions

| Decision | Choice | Rationale |
|---|---|---|
| Direction | Two-way full sync | Editor pushes to server, server pushes to editor |
| Transport | WebSocket via Cloudflare Durable Objects | Already on CF Workers, no extra vendor, ~$5/month |
| Relay model | API-side (CF DO) | Both plugin and browser connect as WS clients to CF |
| Default apply mode | Batch with confirm | Safety: changes accumulate, user clicks Apply |
| Live mode | Toggle in editor UI | Opt-in immediate apply for power users |
| Undo | Server-side undo history (last 20 ops) | Reversible batch applies |
| Message format | Incremental diffs (operations) | Small payloads, natural undo, efficient |
| Conflict handling | Auto-merge non-conflicting, UI for conflicts | Server changes to different entities merge silently |

## Architecture

```
Browser (Next.js) CF Durable Object Hytale Plugin (Java)
| (1 DO per session) |
|--- WS connect -------->| |
| |<-------- WS connect ---------------|
| | |
|-- editor.change ------->| (stores op in pending list) |
| | |
|-- batch.apply --------->| |
| |--- batch.apply ------------------->|
| | [applies to live server]
| |<--- apply.result ------------------|
|<-- apply.result --------| |
| | |
| |<--- server.change -----------------|
|<-- server.change -------| [in-game command ran]
```

### Session Lifecycle

1. Player runs `/hp editor` in-game
2. Plugin creates session via existing REST API (POST /api/session/create)
3. API returns session ID + editor URL (unchanged)
4. Plugin opens WebSocket to `wss://ws.hyperperms.com/session/<id>` as `plugin` role
5. Player opens editor in browser, editor opens WebSocket to same URL as `editor` role
6. DO has both connections -- relay begins
7. On disconnect/timeout, DO hibernates (cost-efficient)
8. Session expires after 24h (same as current TTL)

### Why CF Durable Objects

- CF Workers already in use (no new vendor)
- DOs can hold persistent WebSocket connections (unlike Vercel serverless)
- Built-in hibernation API saves costs when idle
- Native WebSocket support with `acceptWebSocket()` API
- Cost: ~$0.15/M requests + $0.50/GB-month -- effectively free at our volume
- Stays within Cloudflare ecosystem

## Message Protocol

### Operation Types

All messages are JSON with a `type` field. Operations are the atomic unit of change.

```typescript
// === Editor -> DO -> Plugin ===

// Individual changes (accumulated in batch mode, applied immediately in live mode)
{ type: "op", op: "permission.add", target: "group:admin", node: "server.kick", value: true }
{ type: "op", op: "permission.remove", target: "group:admin", node: "server.kick" }
{ type: "op", op: "permission.set", target: "user:<uuid>", node: "chat.color", value: false }
{ type: "op", op: "group.create", name: "moderator", weight: 50, parents: ["default"] }
{ type: "op", op: "group.delete", name: "moderator" }
{ type: "op", op: "group.setMeta", target: "admin", key: "prefix", value: "&c[Admin] " }
{ type: "op", op: "group.setWeight", target: "admin", weight: 100 }
{ type: "op", op: "group.addParent", target: "moderator", parent: "default" }
{ type: "op", op: "group.removeParent", target: "moderator", parent: "default" }
{ type: "op", op: "user.addGroup", target: "<uuid>", group: "admin" }
{ type: "op", op: "user.removeGroup", target: "<uuid>", group: "admin" }
{ type: "op", op: "track.create", name: "staff", groups: ["default", "helper", "mod", "admin"] }
{ type: "op", op: "track.delete", name: "staff" }
{ type: "op", op: "track.addGroup", target: "staff", group: "moderator", position: 2 }
{ type: "op", op: "track.removeGroup", target: "staff", group: "moderator" }

// Batch apply (batch mode only)
{ type: "batch.apply" }

// Mode switch
{ type: "session.mode", mode: "live" | "batch" }

// === Plugin -> DO -> Editor ===

// Server-side change (in-game command, API, another plugin)
{ type: "server.change", op: "permission.add", target: "group:admin", node: "fly.use",
value: true, source: "console", timestamp: 1708617600000 }

// Apply result
{ type: "apply.result", success: true, applied: 5, failed: 0,
errors: [], undoId: "abc123" }

// Undo result
{ type: "undo.result", success: true, undoId: "abc123", reverted: 5 }

// === Control messages (both directions) ===

{ type: "session.sync", data: { groups: [...], users: [...], tracks: [...] } }
{ type: "session.ping" }
{ type: "session.pong" }
{ type: "session.error", code: "CONFLICT", message: "...", details: {...} }
```

### Operation Inversion (for undo)

Each operation has a natural inverse stored in the undo history:

| Operation | Inverse |
|---|---|
| `permission.add` | `permission.remove` (same target/node) |
| `permission.remove` | `permission.add` (with original value) |
| `group.create` | `group.delete` |
| `group.delete` | `group.create` (with full group data snapshot) |
| `group.setWeight` | `group.setWeight` (with previous weight) |
| `user.addGroup` | `user.removeGroup` |
| `user.removeGroup` | `user.addGroup` |
| `track.addGroup` | `track.removeGroup` |

## Conflict Detection

### Non-conflicting (auto-merge)

Server change to entity A while editor has pending changes to entity B:
- Apply server change to editor state silently
- Show subtle indicator that server state updated (e.g. pulse animation on the changed entity)

### Conflicting

Server change to entity A while editor has pending changes to entity A:
- Mark the entity with a conflict indicator in the UI
- Show conflict banner: "Server changed [group: admin] -- your local changes may conflict"
- Options: "Keep mine", "Accept server", "View diff"
- Conflict state clears when user resolves

### Detection Logic

Conflict = same `target` value in both a pending editor op and an incoming server change.
Tracked per-entity, not per-field (simple and predictable).

## Undo History

- Stored in the Durable Object's transactional storage
- Last 20 batch applies, each with its inverse operations
- Each undo entry: `{ undoId, timestamp, ops: Operation[], inverseOps: Operation[] }`
- Undo request: `{ type: "undo", undoId: "abc123" }` -> sends inverse ops to plugin
- History clears on session expiry

## Three Codebases

### 1. CF Workers (new Durable Object)

**Location:** New worker in existing CF project or standalone
**Responsibilities:**
- Accept WebSocket connections from editor and plugin
- Route messages between them based on type
- Store pending operations (batch mode)
- Maintain undo history
- Handle hibernation for cost efficiency
- Session authentication (validate session ID against Upstash Redis)

**Key files:**
- `src/session-do.ts` -- Durable Object class with WebSocket handlers
- `src/index.ts` -- Worker entry point, routes `/session/:id` to DO
- `wrangler.toml` -- DO binding configuration

### 2. HyperPermsWeb (Next.js editor)

**Changes:**
- New WebSocket hook: `useRealtimeSession(sessionId)` -- manages WS connection, reconnection, message handling
- Editor state: track pending operations, conflict state
- UI: "Live" toggle switch, Apply button (batch mode), conflict banners, undo button
- Connection status indicator (connected/reconnecting/disconnected)
- Replace current `PUT /api/session` save flow with WebSocket ops

**Key files:**
- `src/hooks/useRealtimeSession.ts` -- WebSocket connection + state management
- `src/components/editor/RealtimeStatus.tsx` -- Connection indicator
- `src/components/editor/ConflictBanner.tsx` -- Conflict resolution UI
- `src/components/editor/LiveModeToggle.tsx` -- Live/batch toggle
- Modifications to existing editor components to dispatch ops instead of direct state mutation

### 3. HyperPerms (Java plugin)

**Changes:**
- New `WebSocketClient` class using Java 11+ `HttpClient` WebSocket API
- Change listener: hook into existing EventBus to detect in-game permission changes
- Apply handler: receive ops from editor, apply them through existing manager layer
- Undo support: store pre-apply snapshots for revert
- Auto-reconnect on connection loss
- New config options: `webEditor.realtimeEnabled`, `webEditor.wsUrl`

**Key files:**
- `src/main/java/com/hyperperms/web/RealtimeClient.java` -- WebSocket client
- `src/main/java/com/hyperperms/web/RealtimeMessageHandler.java` -- Message routing
- `src/main/java/com/hyperperms/web/OperationApplier.java` -- Applies ops to live state
- `src/main/java/com/hyperperms/web/ChangeListener.java` -- Detects in-game changes, sends to WS
- Modifications to `WebEditorService.java` -- Start WS after session creation

## Security

- Session ID serves as auth token (same as current model)
- DO validates session exists in Redis before accepting WS upgrade
- Plugin identifies as `role: plugin` on connect, editor as `role: editor`
- Only one plugin connection per session (reject duplicates)
- Rate limiting on operations (prevent abuse from editor)
- All traffic over WSS (TLS)

## Migration / Backwards Compatibility

- Existing REST flow continues to work (no breaking changes)
- WebSocket is additive -- if WS connection fails, editor falls back to REST save/load
- Plugin config: `webEditor.realtimeEnabled: true` (default true for new installs)
- `/hp apply` command still works as fallback
7 changes: 6 additions & 1 deletion src/main/java/com/hyperperms/HyperPerms.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -217,7 +217,7 @@ public void enable() {

// Initialize managers with event bus
groupManager = new GroupManagerImpl(storage, cacheInvalidator, eventBus);
trackManager = new TrackManagerImpl(storage);
trackManager = new TrackManagerImpl(storage, eventBus);
userManager = new UserManagerImpl(storage, cache, eventBus, config.getDefaultGroup());

// Load data
Expand DownExpand Up@@ -428,6 +428,11 @@ public void disable() {
placeholderApiIntegration.unregister();
}

// Disconnect WebSocket before shutting down scheduler
if (webEditorService != null) {
webEditorService.disconnectWebSocket();
}

// Stop scheduled tasks FIRST to prevent new storage executor submissions
if (expiryTask != null) {
expiryTask.cancel(true);
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/com/hyperperms/api/events/HyperPermsEvent.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,21 @@ enum EventType {
*/
DATA_RELOAD,

/**
* Fired when a track is created.
*/
TRACK_CREATE,

/**
* Fired when a track is deleted.
*/
TRACK_DELETE,

/**
* Fired when a track is modified.
*/
TRACK_MODIFY,

/**
* Fired when a user is promoted along a track.
*/
Expand Down
112 changes: 112 additions & 0 deletions src/main/java/com/hyperperms/api/events/TrackCreateEvent.java
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
package com.hyperperms.api.events;

import com.hyperperms.model.Track;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Event fired when a track is created.
* <p>
* This event can be cancelled to prevent the track from being created.
* The PRE state fires before creation, the POST state fires after.
*/
public final class TrackCreateEvent implements HyperPermsEvent, Cancellable {

/**
* The state of the track creation event.
*/
public enum State {
/**
* Before the track is created. The event can be cancelled at this point.
*/
PRE,

/**
* After the track has been created. Cancellation has no effect.
*/
POST
}

private final String trackName;
private final Track track;
private final State state;
private boolean cancelled;

/**
* Creates a PRE event for track creation.
*
* @param trackName the name of the track being created
*/
public TrackCreateEvent(@NotNull String trackName) {
this.trackName = trackName;
this.track = null;
this.state = State.PRE;
this.cancelled = false;
}

/**
* Creates a POST event for track creation.
*
* @param track the created track
*/
public TrackCreateEvent(@NotNull Track track) {
this.trackName = track.getName();
this.track = track;
this.state = State.POST;
this.cancelled = false;
}

@Override
public EventType getType() {
return EventType.TRACK_CREATE;
}

/**
* Gets the name of the track being created.
*
* @return the track name
*/
@NotNull
public String getTrackName() {
return trackName;
}

/**
* Gets the created track.
* <p>
* This is only available in the POST state. Returns null in PRE state.
*
* @return the track, or null if PRE state
*/
@Nullable
public Track getTrack() {
return track;
}

/**
* Gets the state of this event.
*
* @return the state
*/
@NotNull
public State getState() {
return state;
}

@Override
public boolean isCancelled() {
return cancelled;
}

@Override
public void setCancelled(boolean cancelled) {
if (state == State.PRE) {
this.cancelled = cancelled;
}
}

@Override
public String toString() {
return "TrackCreateEvent{trackName='" + trackName + "', state=" + state + ", cancelled=" + cancelled + "}";
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ data/
# Temp
*.log
.claude/
.worktrees/
CLAUDE.md
.serena/
.playwright-mcp/
Expand Down
233 changes: 233 additions & 0 deletions docs/plans/2026-02-22-realtime-websocket-editor-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
# Realtime WebSocket Editor Design

**Date:** 2026-02-22
**Status:** Approved
**Scope:** HyperPerms plugin, HyperPermsWeb editor, new CF Workers relay

## Problem

The web editor at hyperperms.com uses a polling/REST model: the plugin POSTs session
data to the API, the user edits in the browser, then runs `/hp apply <session>` to pull
changes back. This is slow, manual, and one-directional.

## Goal

Two-way realtime sync between the browser editor and the Hytale plugin via WebSocket,
so edits flow in both directions without manual commands.

## Decisions

| Decision | Choice | Rationale |
|---|---|---|
| Direction | Two-way full sync | Editor pushes to server, server pushes to editor |
| Transport | WebSocket via Cloudflare Durable Objects | Already on CF Workers, no extra vendor, ~$5/month |
| Relay model | API-side (CF DO) | Both plugin and browser connect as WS clients to CF |
| Default apply mode | Batch with confirm | Safety: changes accumulate, user clicks Apply |
| Live mode | Toggle in editor UI | Opt-in immediate apply for power users |
| Undo | Server-side undo history (last 20 ops) | Reversible batch applies |
| Message format | Incremental diffs (operations) | Small payloads, natural undo, efficient |
| Conflict handling | Auto-merge non-conflicting, UI for conflicts | Server changes to different entities merge silently |

## Architecture

```
Browser (Next.js) CF Durable Object Hytale Plugin (Java)
| (1 DO per session) |
|--- WS connect -------->| |
| |<-------- WS connect ---------------|
| | |
|-- editor.change ------->| (stores op in pending list) |
| | |
|-- batch.apply --------->| |
| |--- batch.apply ------------------->|
| | [applies to live server]
| |<--- apply.result ------------------|
|<-- apply.result --------| |
| | |
| |<--- server.change -----------------|
|<-- server.change -------| [in-game command ran]
```

### Session Lifecycle

1. Player runs `/hp editor` in-game
2. Plugin creates session via existing REST API (POST /api/session/create)
3. API returns session ID + editor URL (unchanged)
4. Plugin opens WebSocket to `wss://ws.hyperperms.com/session/<id>` as `plugin` role
5. Player opens editor in browser, editor opens WebSocket to same URL as `editor` role
6. DO has both connections -- relay begins
7. On disconnect/timeout, DO hibernates (cost-efficient)
8. Session expires after 24h (same as current TTL)

### Why CF Durable Objects

- CF Workers already in use (no new vendor)
- DOs can hold persistent WebSocket connections (unlike Vercel serverless)
- Built-in hibernation API saves costs when idle
- Native WebSocket support with `acceptWebSocket()` API
- Cost: ~$0.15/M requests + $0.50/GB-month -- effectively free at our volume
- Stays within Cloudflare ecosystem

## Message Protocol

### Operation Types

All messages are JSON with a `type` field. Operations are the atomic unit of change.

```typescript
// === Editor -> DO -> Plugin ===

// Individual changes (accumulated in batch mode, applied immediately in live mode)
{ type: "op", op: "permission.add", target: "group:admin", node: "server.kick", value: true }
{ type: "op", op: "permission.remove", target: "group:admin", node: "server.kick" }
{ type: "op", op: "permission.set", target: "user:<uuid>", node: "chat.color", value: false }
{ type: "op", op: "group.create", name: "moderator", weight: 50, parents: ["default"] }
{ type: "op", op: "group.delete", name: "moderator" }
{ type: "op", op: "group.setMeta", target: "admin", key: "prefix", value: "&c[Admin] " }
{ type: "op", op: "group.setWeight", target: "admin", weight: 100 }
{ type: "op", op: "group.addParent", target: "moderator", parent: "default" }
{ type: "op", op: "group.removeParent", target: "moderator", parent: "default" }
{ type: "op", op: "user.addGroup", target: "<uuid>", group: "admin" }
{ type: "op", op: "user.removeGroup", target: "<uuid>", group: "admin" }
{ type: "op", op: "track.create", name: "staff", groups: ["default", "helper", "mod", "admin"] }
{ type: "op", op: "track.delete", name: "staff" }
{ type: "op", op: "track.addGroup", target: "staff", group: "moderator", position: 2 }
{ type: "op", op: "track.removeGroup", target: "staff", group: "moderator" }

// Batch apply (batch mode only)
{ type: "batch.apply" }

// Mode switch
{ type: "session.mode", mode: "live" | "batch" }

// === Plugin -> DO -> Editor ===

// Server-side change (in-game command, API, another plugin)
{ type: "server.change", op: "permission.add", target: "group:admin", node: "fly.use",
value: true, source: "console", timestamp: 1708617600000 }

// Apply result
{ type: "apply.result", success: true, applied: 5, failed: 0,
errors: [], undoId: "abc123" }

// Undo result
{ type: "undo.result", success: true, undoId: "abc123", reverted: 5 }

// === Control messages (both directions) ===

{ type: "session.sync", data: { groups: [...], users: [...], tracks: [...] } }
{ type: "session.ping" }
{ type: "session.pong" }
{ type: "session.error", code: "CONFLICT", message: "...", details: {...} }
```

### Operation Inversion (for undo)

Each operation has a natural inverse stored in the undo history:

| Operation | Inverse |
|---|---|
| `permission.add` | `permission.remove` (same target/node) |
| `permission.remove` | `permission.add` (with original value) |
| `group.create` | `group.delete` |
| `group.delete` | `group.create` (with full group data snapshot) |
| `group.setWeight` | `group.setWeight` (with previous weight) |
| `user.addGroup` | `user.removeGroup` |
| `user.removeGroup` | `user.addGroup` |
| `track.addGroup` | `track.removeGroup` |

## Conflict Detection

### Non-conflicting (auto-merge)

Server change to entity A while editor has pending changes to entity B:
- Apply server change to editor state silently
- Show subtle indicator that server state updated (e.g. pulse animation on the changed entity)

### Conflicting

Server change to entity A while editor has pending changes to entity A:
- Mark the entity with a conflict indicator in the UI
- Show conflict banner: "Server changed [group: admin] -- your local changes may conflict"
- Options: "Keep mine", "Accept server", "View diff"
- Conflict state clears when user resolves

### Detection Logic

Conflict = same `target` value in both a pending editor op and an incoming server change.
Tracked per-entity, not per-field (simple and predictable).

## Undo History

- Stored in the Durable Object's transactional storage
- Last 20 batch applies, each with its inverse operations
- Each undo entry: `{ undoId, timestamp, ops: Operation[], inverseOps: Operation[] }`
- Undo request: `{ type: "undo", undoId: "abc123" }` -> sends inverse ops to plugin
- History clears on session expiry

## Three Codebases

### 1. CF Workers (new Durable Object)

**Location:** New worker in existing CF project or standalone
**Responsibilities:**
- Accept WebSocket connections from editor and plugin
- Route messages between them based on type
- Store pending operations (batch mode)
- Maintain undo history
- Handle hibernation for cost efficiency
- Session authentication (validate session ID against Upstash Redis)

**Key files:**
- `src/session-do.ts` -- Durable Object class with WebSocket handlers
- `src/index.ts` -- Worker entry point, routes `/session/:id` to DO
- `wrangler.toml` -- DO binding configuration

### 2. HyperPermsWeb (Next.js editor)

**Changes:**
- New WebSocket hook: `useRealtimeSession(sessionId)` -- manages WS connection, reconnection, message handling
- Editor state: track pending operations, conflict state
- UI: "Live" toggle switch, Apply button (batch mode), conflict banners, undo button
- Connection status indicator (connected/reconnecting/disconnected)
- Replace current `PUT /api/session` save flow with WebSocket ops

**Key files:**
- `src/hooks/useRealtimeSession.ts` -- WebSocket connection + state management
- `src/components/editor/RealtimeStatus.tsx` -- Connection indicator
- `src/components/editor/ConflictBanner.tsx` -- Conflict resolution UI
- `src/components/editor/LiveModeToggle.tsx` -- Live/batch toggle
- Modifications to existing editor components to dispatch ops instead of direct state mutation

### 3. HyperPerms (Java plugin)

**Changes:**
- New `WebSocketClient` class using Java 11+ `HttpClient` WebSocket API
- Change listener: hook into existing EventBus to detect in-game permission changes
- Apply handler: receive ops from editor, apply them through existing manager layer
- Undo support: store pre-apply snapshots for revert
- Auto-reconnect on connection loss
- New config options: `webEditor.realtimeEnabled`, `webEditor.wsUrl`

**Key files:**
- `src/main/java/com/hyperperms/web/RealtimeClient.java` -- WebSocket client
- `src/main/java/com/hyperperms/web/RealtimeMessageHandler.java` -- Message routing
- `src/main/java/com/hyperperms/web/OperationApplier.java` -- Applies ops to live state
- `src/main/java/com/hyperperms/web/ChangeListener.java` -- Detects in-game changes, sends to WS
- Modifications to `WebEditorService.java` -- Start WS after session creation

## Security

- Session ID serves as auth token (same as current model)
- DO validates session exists in Redis before accepting WS upgrade
- Plugin identifies as `role: plugin` on connect, editor as `role: editor`
- Only one plugin connection per session (reject duplicates)
- Rate limiting on operations (prevent abuse from editor)
- All traffic over WSS (TLS)

## Migration / Backwards Compatibility

- Existing REST flow continues to work (no breaking changes)
- WebSocket is additive -- if WS connection fails, editor falls back to REST save/load
- Plugin config: `webEditor.realtimeEnabled: true` (default true for new installs)
- `/hp apply` command still works as fallback
7 changes: 6 additions & 1 deletion src/main/java/com/hyperperms/HyperPerms.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -217,7 +217,7 @@ public void enable() {

// Initialize managers with event bus
groupManager = new GroupManagerImpl(storage, cacheInvalidator, eventBus);
trackManager = new TrackManagerImpl(storage);
trackManager = new TrackManagerImpl(storage, eventBus);
userManager = new UserManagerImpl(storage, cache, eventBus, config.getDefaultGroup());

// Load data
Expand DownExpand Up@@ -428,6 +428,11 @@ public void disable() {
placeholderApiIntegration.unregister();
}

// Disconnect WebSocket before shutting down scheduler
if (webEditorService != null) {
webEditorService.disconnectWebSocket();
}

// Stop scheduled tasks FIRST to prevent new storage executor submissions
if (expiryTask != null) {
expiryTask.cancel(true);
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/com/hyperperms/api/events/HyperPermsEvent.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,21 @@ enum EventType {
*/
DATA_RELOAD,

/**
* Fired when a track is created.
*/
TRACK_CREATE,

/**
* Fired when a track is deleted.
*/
TRACK_DELETE,

/**
* Fired when a track is modified.
*/
TRACK_MODIFY,

/**
* Fired when a user is promoted along a track.
*/
Expand Down
112 changes: 112 additions & 0 deletions src/main/java/com/hyperperms/api/events/TrackCreateEvent.java
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
package com.hyperperms.api.events;

import com.hyperperms.model.Track;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Event fired when a track is created.
* <p>
* This event can be cancelled to prevent the track from being created.
* The PRE state fires before creation, the POST state fires after.
*/
public final class TrackCreateEvent implements HyperPermsEvent, Cancellable {

/**
* The state of the track creation event.
*/
public enum State {
/**
* Before the track is created. The event can be cancelled at this point.
*/
PRE,

/**
* After the track has been created. Cancellation has no effect.
*/
POST
}

private final String trackName;
private final Track track;
private final State state;
private boolean cancelled;

/**
* Creates a PRE event for track creation.
*
* @param trackName the name of the track being created
*/
public TrackCreateEvent(@NotNull String trackName) {
this.trackName = trackName;
this.track = null;
this.state = State.PRE;
this.cancelled = false;
}

/**
* Creates a POST event for track creation.
*
* @param track the created track
*/
public TrackCreateEvent(@NotNull Track track) {
this.trackName = track.getName();
this.track = track;
this.state = State.POST;
this.cancelled = false;
}

@Override
public EventType getType() {
return EventType.TRACK_CREATE;
}

/**
* Gets the name of the track being created.
*
* @return the track name
*/
@NotNull
public String getTrackName() {
return trackName;
}

/**
* Gets the created track.
* <p>
* This is only available in the POST state. Returns null in PRE state.
*
* @return the track, or null if PRE state
*/
@Nullable
public Track getTrack() {
return track;
}

/**
* Gets the state of this event.
*
* @return the state
*/
@NotNull
public State getState() {
return state;
}

@Override
public boolean isCancelled() {
return cancelled;
}

@Override
public void setCancelled(boolean cancelled) {
if (state == State.PRE) {
this.cancelled = cancelled;
}
}

@Override
public String toString() {
return "TrackCreateEvent{trackName='" + trackName + "', state=" + state + ", cancelled=" + cancelled + "}";
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ data/
# Temp
*.log
.claude/
.worktrees/
CLAUDE.md
.serena/
.playwright-mcp/
Expand Down
233 changes: 233 additions & 0 deletions docs/plans/2026-02-22-realtime-websocket-editor-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
# Realtime WebSocket Editor Design

**Date:** 2026-02-22
**Status:** Approved
**Scope:** HyperPerms plugin, HyperPermsWeb editor, new CF Workers relay

## Problem

The web editor at hyperperms.com uses a polling/REST model: the plugin POSTs session
data to the API, the user edits in the browser, then runs `/hp apply <session>` to pull
changes back. This is slow, manual, and one-directional.

## Goal

Two-way realtime sync between the browser editor and the Hytale plugin via WebSocket,
so edits flow in both directions without manual commands.

## Decisions

| Decision | Choice | Rationale |
|---|---|---|
| Direction | Two-way full sync | Editor pushes to server, server pushes to editor |
| Transport | WebSocket via Cloudflare Durable Objects | Already on CF Workers, no extra vendor, ~$5/month |
| Relay model | API-side (CF DO) | Both plugin and browser connect as WS clients to CF |
| Default apply mode | Batch with confirm | Safety: changes accumulate, user clicks Apply |
| Live mode | Toggle in editor UI | Opt-in immediate apply for power users |
| Undo | Server-side undo history (last 20 ops) | Reversible batch applies |
| Message format | Incremental diffs (operations) | Small payloads, natural undo, efficient |
| Conflict handling | Auto-merge non-conflicting, UI for conflicts | Server changes to different entities merge silently |

## Architecture

```
Browser (Next.js) CF Durable Object Hytale Plugin (Java)
| (1 DO per session) |
|--- WS connect -------->| |
| |<-------- WS connect ---------------|
| | |
|-- editor.change ------->| (stores op in pending list) |
| | |
|-- batch.apply --------->| |
| |--- batch.apply ------------------->|
| | [applies to live server]
| |<--- apply.result ------------------|
|<-- apply.result --------| |
| | |
| |<--- server.change -----------------|
|<-- server.change -------| [in-game command ran]
```

### Session Lifecycle

1. Player runs `/hp editor` in-game
2. Plugin creates session via existing REST API (POST /api/session/create)
3. API returns session ID + editor URL (unchanged)
4. Plugin opens WebSocket to `wss://ws.hyperperms.com/session/<id>` as `plugin` role
5. Player opens editor in browser, editor opens WebSocket to same URL as `editor` role
6. DO has both connections -- relay begins
7. On disconnect/timeout, DO hibernates (cost-efficient)
8. Session expires after 24h (same as current TTL)

### Why CF Durable Objects

- CF Workers already in use (no new vendor)
- DOs can hold persistent WebSocket connections (unlike Vercel serverless)
- Built-in hibernation API saves costs when idle
- Native WebSocket support with `acceptWebSocket()` API
- Cost: ~$0.15/M requests + $0.50/GB-month -- effectively free at our volume
- Stays within Cloudflare ecosystem

## Message Protocol

### Operation Types

All messages are JSON with a `type` field. Operations are the atomic unit of change.

```typescript
// === Editor -> DO -> Plugin ===

// Individual changes (accumulated in batch mode, applied immediately in live mode)
{ type: "op", op: "permission.add", target: "group:admin", node: "server.kick", value: true }
{ type: "op", op: "permission.remove", target: "group:admin", node: "server.kick" }
{ type: "op", op: "permission.set", target: "user:<uuid>", node: "chat.color", value: false }
{ type: "op", op: "group.create", name: "moderator", weight: 50, parents: ["default"] }
{ type: "op", op: "group.delete", name: "moderator" }
{ type: "op", op: "group.setMeta", target: "admin", key: "prefix", value: "&c[Admin] " }
{ type: "op", op: "group.setWeight", target: "admin", weight: 100 }
{ type: "op", op: "group.addParent", target: "moderator", parent: "default" }
{ type: "op", op: "group.removeParent", target: "moderator", parent: "default" }
{ type: "op", op: "user.addGroup", target: "<uuid>", group: "admin" }
{ type: "op", op: "user.removeGroup", target: "<uuid>", group: "admin" }
{ type: "op", op: "track.create", name: "staff", groups: ["default", "helper", "mod", "admin"] }
{ type: "op", op: "track.delete", name: "staff" }
{ type: "op", op: "track.addGroup", target: "staff", group: "moderator", position: 2 }
{ type: "op", op: "track.removeGroup", target: "staff", group: "moderator" }

// Batch apply (batch mode only)
{ type: "batch.apply" }

// Mode switch
{ type: "session.mode", mode: "live" | "batch" }

// === Plugin -> DO -> Editor ===

// Server-side change (in-game command, API, another plugin)
{ type: "server.change", op: "permission.add", target: "group:admin", node: "fly.use",
value: true, source: "console", timestamp: 1708617600000 }

// Apply result
{ type: "apply.result", success: true, applied: 5, failed: 0,
errors: [], undoId: "abc123" }

// Undo result
{ type: "undo.result", success: true, undoId: "abc123", reverted: 5 }

// === Control messages (both directions) ===

{ type: "session.sync", data: { groups: [...], users: [...], tracks: [...] } }
{ type: "session.ping" }
{ type: "session.pong" }
{ type: "session.error", code: "CONFLICT", message: "...", details: {...} }
```

### Operation Inversion (for undo)

Each operation has a natural inverse stored in the undo history:

| Operation | Inverse |
|---|---|
| `permission.add` | `permission.remove` (same target/node) |
| `permission.remove` | `permission.add` (with original value) |
| `group.create` | `group.delete` |
| `group.delete` | `group.create` (with full group data snapshot) |
| `group.setWeight` | `group.setWeight` (with previous weight) |
| `user.addGroup` | `user.removeGroup` |
| `user.removeGroup` | `user.addGroup` |
| `track.addGroup` | `track.removeGroup` |

## Conflict Detection

### Non-conflicting (auto-merge)

Server change to entity A while editor has pending changes to entity B:
- Apply server change to editor state silently
- Show subtle indicator that server state updated (e.g. pulse animation on the changed entity)

### Conflicting

Server change to entity A while editor has pending changes to entity A:
- Mark the entity with a conflict indicator in the UI
- Show conflict banner: "Server changed [group: admin] -- your local changes may conflict"
- Options: "Keep mine", "Accept server", "View diff"
- Conflict state clears when user resolves

### Detection Logic

Conflict = same `target` value in both a pending editor op and an incoming server change.
Tracked per-entity, not per-field (simple and predictable).

## Undo History

- Stored in the Durable Object's transactional storage
- Last 20 batch applies, each with its inverse operations
- Each undo entry: `{ undoId, timestamp, ops: Operation[], inverseOps: Operation[] }`
- Undo request: `{ type: "undo", undoId: "abc123" }` -> sends inverse ops to plugin
- History clears on session expiry

## Three Codebases

### 1. CF Workers (new Durable Object)

**Location:** New worker in existing CF project or standalone
**Responsibilities:**
- Accept WebSocket connections from editor and plugin
- Route messages between them based on type
- Store pending operations (batch mode)
- Maintain undo history
- Handle hibernation for cost efficiency
- Session authentication (validate session ID against Upstash Redis)

**Key files:**
- `src/session-do.ts` -- Durable Object class with WebSocket handlers
- `src/index.ts` -- Worker entry point, routes `/session/:id` to DO
- `wrangler.toml` -- DO binding configuration

### 2. HyperPermsWeb (Next.js editor)

**Changes:**
- New WebSocket hook: `useRealtimeSession(sessionId)` -- manages WS connection, reconnection, message handling
- Editor state: track pending operations, conflict state
- UI: "Live" toggle switch, Apply button (batch mode), conflict banners, undo button
- Connection status indicator (connected/reconnecting/disconnected)
- Replace current `PUT /api/session` save flow with WebSocket ops

**Key files:**
- `src/hooks/useRealtimeSession.ts` -- WebSocket connection + state management
- `src/components/editor/RealtimeStatus.tsx` -- Connection indicator
- `src/components/editor/ConflictBanner.tsx` -- Conflict resolution UI
- `src/components/editor/LiveModeToggle.tsx` -- Live/batch toggle
- Modifications to existing editor components to dispatch ops instead of direct state mutation

### 3. HyperPerms (Java plugin)

**Changes:**
- New `WebSocketClient` class using Java 11+ `HttpClient` WebSocket API
- Change listener: hook into existing EventBus to detect in-game permission changes
- Apply handler: receive ops from editor, apply them through existing manager layer
- Undo support: store pre-apply snapshots for revert
- Auto-reconnect on connection loss
- New config options: `webEditor.realtimeEnabled`, `webEditor.wsUrl`

**Key files:**
- `src/main/java/com/hyperperms/web/RealtimeClient.java` -- WebSocket client
- `src/main/java/com/hyperperms/web/RealtimeMessageHandler.java` -- Message routing
- `src/main/java/com/hyperperms/web/OperationApplier.java` -- Applies ops to live state
- `src/main/java/com/hyperperms/web/ChangeListener.java` -- Detects in-game changes, sends to WS
- Modifications to `WebEditorService.java` -- Start WS after session creation

## Security

- Session ID serves as auth token (same as current model)
- DO validates session exists in Redis before accepting WS upgrade
- Plugin identifies as `role: plugin` on connect, editor as `role: editor`
- Only one plugin connection per session (reject duplicates)
- Rate limiting on operations (prevent abuse from editor)
- All traffic over WSS (TLS)

## Migration / Backwards Compatibility

- Existing REST flow continues to work (no breaking changes)
- WebSocket is additive -- if WS connection fails, editor falls back to REST save/load
- Plugin config: `webEditor.realtimeEnabled: true` (default true for new installs)
- `/hp apply` command still works as fallback
7 changes: 6 additions & 1 deletion src/main/java/com/hyperperms/HyperPerms.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -217,7 +217,7 @@ public void enable() {

// Initialize managers with event bus
groupManager = new GroupManagerImpl(storage, cacheInvalidator, eventBus);
trackManager = new TrackManagerImpl(storage);
trackManager = new TrackManagerImpl(storage, eventBus);
userManager = new UserManagerImpl(storage, cache, eventBus, config.getDefaultGroup());

// Load data
Expand DownExpand Up@@ -428,6 +428,11 @@ public void disable() {
placeholderApiIntegration.unregister();
}

// Disconnect WebSocket before shutting down scheduler
if (webEditorService != null) {
webEditorService.disconnectWebSocket();
}

// Stop scheduled tasks FIRST to prevent new storage executor submissions
if (expiryTask != null) {
expiryTask.cancel(true);
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/com/hyperperms/api/events/HyperPermsEvent.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,21 @@ enum EventType {
*/
DATA_RELOAD,

/**
* Fired when a track is created.
*/
TRACK_CREATE,

/**
* Fired when a track is deleted.
*/
TRACK_DELETE,

/**
* Fired when a track is modified.
*/
TRACK_MODIFY,

/**
* Fired when a user is promoted along a track.
*/
Expand Down
112 changes: 112 additions & 0 deletions src/main/java/com/hyperperms/api/events/TrackCreateEvent.java
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
package com.hyperperms.api.events;

import com.hyperperms.model.Track;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Event fired when a track is created.
* <p>
* This event can be cancelled to prevent the track from being created.
* The PRE state fires before creation, the POST state fires after.
*/
public final class TrackCreateEvent implements HyperPermsEvent, Cancellable {

/**
* The state of the track creation event.
*/
public enum State {
/**
* Before the track is created. The event can be cancelled at this point.
*/
PRE,

/**
* After the track has been created. Cancellation has no effect.
*/
POST
}

private final String trackName;
private final Track track;
private final State state;
private boolean cancelled;

/**
* Creates a PRE event for track creation.
*
* @param trackName the name of the track being created
*/
public TrackCreateEvent(@NotNull String trackName) {
this.trackName = trackName;
this.track = null;
this.state = State.PRE;
this.cancelled = false;
}

/**
* Creates a POST event for track creation.
*
* @param track the created track
*/
public TrackCreateEvent(@NotNull Track track) {
this.trackName = track.getName();
this.track = track;
this.state = State.POST;
this.cancelled = false;
}

@Override
public EventType getType() {
return EventType.TRACK_CREATE;
}

/**
* Gets the name of the track being created.
*
* @return the track name
*/
@NotNull
public String getTrackName() {
return trackName;
}

/**
* Gets the created track.
* <p>
* This is only available in the POST state. Returns null in PRE state.
*
* @return the track, or null if PRE state
*/
@Nullable
public Track getTrack() {
return track;
}

/**
* Gets the state of this event.
*
* @return the state
*/
@NotNull
public State getState() {
return state;
}

@Override
public boolean isCancelled() {
return cancelled;
}

@Override
public void setCancelled(boolean cancelled) {
if (state == State.PRE) {
this.cancelled = cancelled;
}
}

@Override
public String toString() {
return "TrackCreateEvent{trackName='" + trackName + "', state=" + state + ", cancelled=" + cancelled + "}";
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ data/
# Temp
*.log
.claude/
.worktrees/
CLAUDE.md
.serena/
.playwright-mcp/
Expand Down
233 changes: 233 additions & 0 deletions docs/plans/2026-02-22-realtime-websocket-editor-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
# Realtime WebSocket Editor Design

**Date:** 2026-02-22
**Status:** Approved
**Scope:** HyperPerms plugin, HyperPermsWeb editor, new CF Workers relay

## Problem

The web editor at hyperperms.com uses a polling/REST model: the plugin POSTs session
data to the API, the user edits in the browser, then runs `/hp apply <session>` to pull
changes back. This is slow, manual, and one-directional.

## Goal

Two-way realtime sync between the browser editor and the Hytale plugin via WebSocket,
so edits flow in both directions without manual commands.

## Decisions

| Decision | Choice | Rationale |
|---|---|---|
| Direction | Two-way full sync | Editor pushes to server, server pushes to editor |
| Transport | WebSocket via Cloudflare Durable Objects | Already on CF Workers, no extra vendor, ~$5/month |
| Relay model | API-side (CF DO) | Both plugin and browser connect as WS clients to CF |
| Default apply mode | Batch with confirm | Safety: changes accumulate, user clicks Apply |
| Live mode | Toggle in editor UI | Opt-in immediate apply for power users |
| Undo | Server-side undo history (last 20 ops) | Reversible batch applies |
| Message format | Incremental diffs (operations) | Small payloads, natural undo, efficient |
| Conflict handling | Auto-merge non-conflicting, UI for conflicts | Server changes to different entities merge silently |

## Architecture

```
Browser (Next.js) CF Durable Object Hytale Plugin (Java)
| (1 DO per session) |
|--- WS connect -------->| |
| |<-------- WS connect ---------------|
| | |
|-- editor.change ------->| (stores op in pending list) |
| | |
|-- batch.apply --------->| |
| |--- batch.apply ------------------->|
| | [applies to live server]
| |<--- apply.result ------------------|
|<-- apply.result --------| |
| | |
| |<--- server.change -----------------|
|<-- server.change -------| [in-game command ran]
```

### Session Lifecycle

1. Player runs `/hp editor` in-game
2. Plugin creates session via existing REST API (POST /api/session/create)
3. API returns session ID + editor URL (unchanged)
4. Plugin opens WebSocket to `wss://ws.hyperperms.com/session/<id>` as `plugin` role
5. Player opens editor in browser, editor opens WebSocket to same URL as `editor` role
6. DO has both connections -- relay begins
7. On disconnect/timeout, DO hibernates (cost-efficient)
8. Session expires after 24h (same as current TTL)

### Why CF Durable Objects

- CF Workers already in use (no new vendor)
- DOs can hold persistent WebSocket connections (unlike Vercel serverless)
- Built-in hibernation API saves costs when idle
- Native WebSocket support with `acceptWebSocket()` API
- Cost: ~$0.15/M requests + $0.50/GB-month -- effectively free at our volume
- Stays within Cloudflare ecosystem

## Message Protocol

### Operation Types

All messages are JSON with a `type` field. Operations are the atomic unit of change.

```typescript
// === Editor -> DO -> Plugin ===

// Individual changes (accumulated in batch mode, applied immediately in live mode)
{ type: "op", op: "permission.add", target: "group:admin", node: "server.kick", value: true }
{ type: "op", op: "permission.remove", target: "group:admin", node: "server.kick" }
{ type: "op", op: "permission.set", target: "user:<uuid>", node: "chat.color", value: false }
{ type: "op", op: "group.create", name: "moderator", weight: 50, parents: ["default"] }
{ type: "op", op: "group.delete", name: "moderator" }
{ type: "op", op: "group.setMeta", target: "admin", key: "prefix", value: "&c[Admin] " }
{ type: "op", op: "group.setWeight", target: "admin", weight: 100 }
{ type: "op", op: "group.addParent", target: "moderator", parent: "default" }
{ type: "op", op: "group.removeParent", target: "moderator", parent: "default" }
{ type: "op", op: "user.addGroup", target: "<uuid>", group: "admin" }
{ type: "op", op: "user.removeGroup", target: "<uuid>", group: "admin" }
{ type: "op", op: "track.create", name: "staff", groups: ["default", "helper", "mod", "admin"] }
{ type: "op", op: "track.delete", name: "staff" }
{ type: "op", op: "track.addGroup", target: "staff", group: "moderator", position: 2 }
{ type: "op", op: "track.removeGroup", target: "staff", group: "moderator" }

// Batch apply (batch mode only)
{ type: "batch.apply" }

// Mode switch
{ type: "session.mode", mode: "live" | "batch" }

// === Plugin -> DO -> Editor ===

// Server-side change (in-game command, API, another plugin)
{ type: "server.change", op: "permission.add", target: "group:admin", node: "fly.use",
value: true, source: "console", timestamp: 1708617600000 }

// Apply result
{ type: "apply.result", success: true, applied: 5, failed: 0,
errors: [], undoId: "abc123" }

// Undo result
{ type: "undo.result", success: true, undoId: "abc123", reverted: 5 }

// === Control messages (both directions) ===

{ type: "session.sync", data: { groups: [...], users: [...], tracks: [...] } }
{ type: "session.ping" }
{ type: "session.pong" }
{ type: "session.error", code: "CONFLICT", message: "...", details: {...} }
```

### Operation Inversion (for undo)

Each operation has a natural inverse stored in the undo history:

| Operation | Inverse |
|---|---|
| `permission.add` | `permission.remove` (same target/node) |
| `permission.remove` | `permission.add` (with original value) |
| `group.create` | `group.delete` |
| `group.delete` | `group.create` (with full group data snapshot) |
| `group.setWeight` | `group.setWeight` (with previous weight) |
| `user.addGroup` | `user.removeGroup` |
| `user.removeGroup` | `user.addGroup` |
| `track.addGroup` | `track.removeGroup` |

## Conflict Detection

### Non-conflicting (auto-merge)

Server change to entity A while editor has pending changes to entity B:
- Apply server change to editor state silently
- Show subtle indicator that server state updated (e.g. pulse animation on the changed entity)

### Conflicting

Server change to entity A while editor has pending changes to entity A:
- Mark the entity with a conflict indicator in the UI
- Show conflict banner: "Server changed [group: admin] -- your local changes may conflict"
- Options: "Keep mine", "Accept server", "View diff"
- Conflict state clears when user resolves

### Detection Logic

Conflict = same `target` value in both a pending editor op and an incoming server change.
Tracked per-entity, not per-field (simple and predictable).

## Undo History

- Stored in the Durable Object's transactional storage
- Last 20 batch applies, each with its inverse operations
- Each undo entry: `{ undoId, timestamp, ops: Operation[], inverseOps: Operation[] }`
- Undo request: `{ type: "undo", undoId: "abc123" }` -> sends inverse ops to plugin
- History clears on session expiry

## Three Codebases

### 1. CF Workers (new Durable Object)

**Location:** New worker in existing CF project or standalone
**Responsibilities:**
- Accept WebSocket connections from editor and plugin
- Route messages between them based on type
- Store pending operations (batch mode)
- Maintain undo history
- Handle hibernation for cost efficiency
- Session authentication (validate session ID against Upstash Redis)

**Key files:**
- `src/session-do.ts` -- Durable Object class with WebSocket handlers
- `src/index.ts` -- Worker entry point, routes `/session/:id` to DO
- `wrangler.toml` -- DO binding configuration

### 2. HyperPermsWeb (Next.js editor)

**Changes:**
- New WebSocket hook: `useRealtimeSession(sessionId)` -- manages WS connection, reconnection, message handling
- Editor state: track pending operations, conflict state
- UI: "Live" toggle switch, Apply button (batch mode), conflict banners, undo button
- Connection status indicator (connected/reconnecting/disconnected)
- Replace current `PUT /api/session` save flow with WebSocket ops

**Key files:**
- `src/hooks/useRealtimeSession.ts` -- WebSocket connection + state management
- `src/components/editor/RealtimeStatus.tsx` -- Connection indicator
- `src/components/editor/ConflictBanner.tsx` -- Conflict resolution UI
- `src/components/editor/LiveModeToggle.tsx` -- Live/batch toggle
- Modifications to existing editor components to dispatch ops instead of direct state mutation

### 3. HyperPerms (Java plugin)

**Changes:**
- New `WebSocketClient` class using Java 11+ `HttpClient` WebSocket API
- Change listener: hook into existing EventBus to detect in-game permission changes
- Apply handler: receive ops from editor, apply them through existing manager layer
- Undo support: store pre-apply snapshots for revert
- Auto-reconnect on connection loss
- New config options: `webEditor.realtimeEnabled`, `webEditor.wsUrl`

**Key files:**
- `src/main/java/com/hyperperms/web/RealtimeClient.java` -- WebSocket client
- `src/main/java/com/hyperperms/web/RealtimeMessageHandler.java` -- Message routing
- `src/main/java/com/hyperperms/web/OperationApplier.java` -- Applies ops to live state
- `src/main/java/com/hyperperms/web/ChangeListener.java` -- Detects in-game changes, sends to WS
- Modifications to `WebEditorService.java` -- Start WS after session creation

## Security

- Session ID serves as auth token (same as current model)
- DO validates session exists in Redis before accepting WS upgrade
- Plugin identifies as `role: plugin` on connect, editor as `role: editor`
- Only one plugin connection per session (reject duplicates)
- Rate limiting on operations (prevent abuse from editor)
- All traffic over WSS (TLS)

## Migration / Backwards Compatibility

- Existing REST flow continues to work (no breaking changes)
- WebSocket is additive -- if WS connection fails, editor falls back to REST save/load
- Plugin config: `webEditor.realtimeEnabled: true` (default true for new installs)
- `/hp apply` command still works as fallback
7 changes: 6 additions & 1 deletion src/main/java/com/hyperperms/HyperPerms.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -217,7 +217,7 @@ public void enable() {

// Initialize managers with event bus
groupManager = new GroupManagerImpl(storage, cacheInvalidator, eventBus);
trackManager = new TrackManagerImpl(storage);
trackManager = new TrackManagerImpl(storage, eventBus);
userManager = new UserManagerImpl(storage, cache, eventBus, config.getDefaultGroup());

// Load data
Expand DownExpand Up@@ -428,6 +428,11 @@ public void disable() {
placeholderApiIntegration.unregister();
}

// Disconnect WebSocket before shutting down scheduler
if (webEditorService != null) {
webEditorService.disconnectWebSocket();
}

// Stop scheduled tasks FIRST to prevent new storage executor submissions
if (expiryTask != null) {
expiryTask.cancel(true);
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/com/hyperperms/api/events/HyperPermsEvent.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,21 @@ enum EventType {
*/
DATA_RELOAD,

/**
* Fired when a track is created.
*/
TRACK_CREATE,

/**
* Fired when a track is deleted.
*/
TRACK_DELETE,

/**
* Fired when a track is modified.
*/
TRACK_MODIFY,

/**
* Fired when a user is promoted along a track.
*/
Expand Down
112 changes: 112 additions & 0 deletions src/main/java/com/hyperperms/api/events/TrackCreateEvent.java
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
package com.hyperperms.api.events;

import com.hyperperms.model.Track;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Event fired when a track is created.
* <p>
* This event can be cancelled to prevent the track from being created.
* The PRE state fires before creation, the POST state fires after.
*/
public final class TrackCreateEvent implements HyperPermsEvent, Cancellable {

/**
* The state of the track creation event.
*/
public enum State {
/**
* Before the track is created. The event can be cancelled at this point.
*/
PRE,

/**
* After the track has been created. Cancellation has no effect.
*/
POST
}

private final String trackName;
private final Track track;
private final State state;
private boolean cancelled;

/**
* Creates a PRE event for track creation.
*
* @param trackName the name of the track being created
*/
public TrackCreateEvent(@NotNull String trackName) {
this.trackName = trackName;
this.track = null;
this.state = State.PRE;
this.cancelled = false;
}

/**
* Creates a POST event for track creation.
*
* @param track the created track
*/
public TrackCreateEvent(@NotNull Track track) {
this.trackName = track.getName();
this.track = track;
this.state = State.POST;
this.cancelled = false;
}

@Override
public EventType getType() {
return EventType.TRACK_CREATE;
}

/**
* Gets the name of the track being created.
*
* @return the track name
*/
@NotNull
public String getTrackName() {
return trackName;
}

/**
* Gets the created track.
* <p>
* This is only available in the POST state. Returns null in PRE state.
*
* @return the track, or null if PRE state
*/
@Nullable
public Track getTrack() {
return track;
}

/**
* Gets the state of this event.
*
* @return the state
*/
@NotNull
public State getState() {
return state;
}

@Override
public boolean isCancelled() {
return cancelled;
}

@Override
public void setCancelled(boolean cancelled) {
if (state == State.PRE) {
this.cancelled = cancelled;
}
}

@Override
public String toString() {
return "TrackCreateEvent{trackName='" + trackName + "', state=" + state + ", cancelled=" + cancelled + "}";
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ data/
# Temp
*.log
.claude/
.worktrees/
CLAUDE.md
.serena/
.playwright-mcp/
Expand Down
233 changes: 233 additions & 0 deletions docs/plans/2026-02-22-realtime-websocket-editor-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
# Realtime WebSocket Editor Design

**Date:** 2026-02-22
**Status:** Approved
**Scope:** HyperPerms plugin, HyperPermsWeb editor, new CF Workers relay

## Problem

The web editor at hyperperms.com uses a polling/REST model: the plugin POSTs session
data to the API, the user edits in the browser, then runs `/hp apply <session>` to pull
changes back. This is slow, manual, and one-directional.

## Goal

Two-way realtime sync between the browser editor and the Hytale plugin via WebSocket,
so edits flow in both directions without manual commands.

## Decisions

| Decision | Choice | Rationale |
|---|---|---|
| Direction | Two-way full sync | Editor pushes to server, server pushes to editor |
| Transport | WebSocket via Cloudflare Durable Objects | Already on CF Workers, no extra vendor, ~$5/month |
| Relay model | API-side (CF DO) | Both plugin and browser connect as WS clients to CF |
| Default apply mode | Batch with confirm | Safety: changes accumulate, user clicks Apply |
| Live mode | Toggle in editor UI | Opt-in immediate apply for power users |
| Undo | Server-side undo history (last 20 ops) | Reversible batch applies |
| Message format | Incremental diffs (operations) | Small payloads, natural undo, efficient |
| Conflict handling | Auto-merge non-conflicting, UI for conflicts | Server changes to different entities merge silently |

## Architecture

```
Browser (Next.js) CF Durable Object Hytale Plugin (Java)
| (1 DO per session) |
|--- WS connect -------->| |
| |<-------- WS connect ---------------|
| | |
|-- editor.change ------->| (stores op in pending list) |
| | |
|-- batch.apply --------->| |
| |--- batch.apply ------------------->|
| | [applies to live server]
| |<--- apply.result ------------------|
|<-- apply.result --------| |
| | |
| |<--- server.change -----------------|
|<-- server.change -------| [in-game command ran]
```

### Session Lifecycle

1. Player runs `/hp editor` in-game
2. Plugin creates session via existing REST API (POST /api/session/create)
3. API returns session ID + editor URL (unchanged)
4. Plugin opens WebSocket to `wss://ws.hyperperms.com/session/<id>` as `plugin` role
5. Player opens editor in browser, editor opens WebSocket to same URL as `editor` role
6. DO has both connections -- relay begins
7. On disconnect/timeout, DO hibernates (cost-efficient)
8. Session expires after 24h (same as current TTL)

### Why CF Durable Objects

- CF Workers already in use (no new vendor)
- DOs can hold persistent WebSocket connections (unlike Vercel serverless)
- Built-in hibernation API saves costs when idle
- Native WebSocket support with `acceptWebSocket()` API
- Cost: ~$0.15/M requests + $0.50/GB-month -- effectively free at our volume
- Stays within Cloudflare ecosystem

## Message Protocol

### Operation Types

All messages are JSON with a `type` field. Operations are the atomic unit of change.

```typescript
// === Editor -> DO -> Plugin ===

// Individual changes (accumulated in batch mode, applied immediately in live mode)
{ type: "op", op: "permission.add", target: "group:admin", node: "server.kick", value: true }
{ type: "op", op: "permission.remove", target: "group:admin", node: "server.kick" }
{ type: "op", op: "permission.set", target: "user:<uuid>", node: "chat.color", value: false }
{ type: "op", op: "group.create", name: "moderator", weight: 50, parents: ["default"] }
{ type: "op", op: "group.delete", name: "moderator" }
{ type: "op", op: "group.setMeta", target: "admin", key: "prefix", value: "&c[Admin] " }
{ type: "op", op: "group.setWeight", target: "admin", weight: 100 }
{ type: "op", op: "group.addParent", target: "moderator", parent: "default" }
{ type: "op", op: "group.removeParent", target: "moderator", parent: "default" }
{ type: "op", op: "user.addGroup", target: "<uuid>", group: "admin" }
{ type: "op", op: "user.removeGroup", target: "<uuid>", group: "admin" }
{ type: "op", op: "track.create", name: "staff", groups: ["default", "helper", "mod", "admin"] }
{ type: "op", op: "track.delete", name: "staff" }
{ type: "op", op: "track.addGroup", target: "staff", group: "moderator", position: 2 }
{ type: "op", op: "track.removeGroup", target: "staff", group: "moderator" }

// Batch apply (batch mode only)
{ type: "batch.apply" }

// Mode switch
{ type: "session.mode", mode: "live" | "batch" }

// === Plugin -> DO -> Editor ===

// Server-side change (in-game command, API, another plugin)
{ type: "server.change", op: "permission.add", target: "group:admin", node: "fly.use",
value: true, source: "console", timestamp: 1708617600000 }

// Apply result
{ type: "apply.result", success: true, applied: 5, failed: 0,
errors: [], undoId: "abc123" }

// Undo result
{ type: "undo.result", success: true, undoId: "abc123", reverted: 5 }

// === Control messages (both directions) ===

{ type: "session.sync", data: { groups: [...], users: [...], tracks: [...] } }
{ type: "session.ping" }
{ type: "session.pong" }
{ type: "session.error", code: "CONFLICT", message: "...", details: {...} }
```

### Operation Inversion (for undo)

Each operation has a natural inverse stored in the undo history:

| Operation | Inverse |
|---|---|
| `permission.add` | `permission.remove` (same target/node) |
| `permission.remove` | `permission.add` (with original value) |
| `group.create` | `group.delete` |
| `group.delete` | `group.create` (with full group data snapshot) |
| `group.setWeight` | `group.setWeight` (with previous weight) |
| `user.addGroup` | `user.removeGroup` |
| `user.removeGroup` | `user.addGroup` |
| `track.addGroup` | `track.removeGroup` |

## Conflict Detection

### Non-conflicting (auto-merge)

Server change to entity A while editor has pending changes to entity B:
- Apply server change to editor state silently
- Show subtle indicator that server state updated (e.g. pulse animation on the changed entity)

### Conflicting

Server change to entity A while editor has pending changes to entity A:
- Mark the entity with a conflict indicator in the UI
- Show conflict banner: "Server changed [group: admin] -- your local changes may conflict"
- Options: "Keep mine", "Accept server", "View diff"
- Conflict state clears when user resolves

### Detection Logic

Conflict = same `target` value in both a pending editor op and an incoming server change.
Tracked per-entity, not per-field (simple and predictable).

## Undo History

- Stored in the Durable Object's transactional storage
- Last 20 batch applies, each with its inverse operations
- Each undo entry: `{ undoId, timestamp, ops: Operation[], inverseOps: Operation[] }`
- Undo request: `{ type: "undo", undoId: "abc123" }` -> sends inverse ops to plugin
- History clears on session expiry

## Three Codebases

### 1. CF Workers (new Durable Object)

**Location:** New worker in existing CF project or standalone
**Responsibilities:**
- Accept WebSocket connections from editor and plugin
- Route messages between them based on type
- Store pending operations (batch mode)
- Maintain undo history
- Handle hibernation for cost efficiency
- Session authentication (validate session ID against Upstash Redis)

**Key files:**
- `src/session-do.ts` -- Durable Object class with WebSocket handlers
- `src/index.ts` -- Worker entry point, routes `/session/:id` to DO
- `wrangler.toml` -- DO binding configuration

### 2. HyperPermsWeb (Next.js editor)

**Changes:**
- New WebSocket hook: `useRealtimeSession(sessionId)` -- manages WS connection, reconnection, message handling
- Editor state: track pending operations, conflict state
- UI: "Live" toggle switch, Apply button (batch mode), conflict banners, undo button
- Connection status indicator (connected/reconnecting/disconnected)
- Replace current `PUT /api/session` save flow with WebSocket ops

**Key files:**
- `src/hooks/useRealtimeSession.ts` -- WebSocket connection + state management
- `src/components/editor/RealtimeStatus.tsx` -- Connection indicator
- `src/components/editor/ConflictBanner.tsx` -- Conflict resolution UI
- `src/components/editor/LiveModeToggle.tsx` -- Live/batch toggle
- Modifications to existing editor components to dispatch ops instead of direct state mutation

### 3. HyperPerms (Java plugin)

**Changes:**
- New `WebSocketClient` class using Java 11+ `HttpClient` WebSocket API
- Change listener: hook into existing EventBus to detect in-game permission changes
- Apply handler: receive ops from editor, apply them through existing manager layer
- Undo support: store pre-apply snapshots for revert
- Auto-reconnect on connection loss
- New config options: `webEditor.realtimeEnabled`, `webEditor.wsUrl`

**Key files:**
- `src/main/java/com/hyperperms/web/RealtimeClient.java` -- WebSocket client
- `src/main/java/com/hyperperms/web/RealtimeMessageHandler.java` -- Message routing
- `src/main/java/com/hyperperms/web/OperationApplier.java` -- Applies ops to live state
- `src/main/java/com/hyperperms/web/ChangeListener.java` -- Detects in-game changes, sends to WS
- Modifications to `WebEditorService.java` -- Start WS after session creation

## Security

- Session ID serves as auth token (same as current model)
- DO validates session exists in Redis before accepting WS upgrade
- Plugin identifies as `role: plugin` on connect, editor as `role: editor`
- Only one plugin connection per session (reject duplicates)
- Rate limiting on operations (prevent abuse from editor)
- All traffic over WSS (TLS)

## Migration / Backwards Compatibility

- Existing REST flow continues to work (no breaking changes)
- WebSocket is additive -- if WS connection fails, editor falls back to REST save/load
- Plugin config: `webEditor.realtimeEnabled: true` (default true for new installs)
- `/hp apply` command still works as fallback
7 changes: 6 additions & 1 deletion src/main/java/com/hyperperms/HyperPerms.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -217,7 +217,7 @@ public void enable() {

// Initialize managers with event bus
groupManager = new GroupManagerImpl(storage, cacheInvalidator, eventBus);
trackManager = new TrackManagerImpl(storage);
trackManager = new TrackManagerImpl(storage, eventBus);
userManager = new UserManagerImpl(storage, cache, eventBus, config.getDefaultGroup());

// Load data
Expand DownExpand Up@@ -428,6 +428,11 @@ public void disable() {
placeholderApiIntegration.unregister();
}

// Disconnect WebSocket before shutting down scheduler
if (webEditorService != null) {
webEditorService.disconnectWebSocket();
}

// Stop scheduled tasks FIRST to prevent new storage executor submissions
if (expiryTask != null) {
expiryTask.cancel(true);
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/com/hyperperms/api/events/HyperPermsEvent.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,21 @@ enum EventType {
*/
DATA_RELOAD,

/**
* Fired when a track is created.
*/
TRACK_CREATE,

/**
* Fired when a track is deleted.
*/
TRACK_DELETE,

/**
* Fired when a track is modified.
*/
TRACK_MODIFY,

/**
* Fired when a user is promoted along a track.
*/
Expand Down
112 changes: 112 additions & 0 deletions src/main/java/com/hyperperms/api/events/TrackCreateEvent.java
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
package com.hyperperms.api.events;

import com.hyperperms.model.Track;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Event fired when a track is created.
* <p>
* This event can be cancelled to prevent the track from being created.
* The PRE state fires before creation, the POST state fires after.
*/
public final class TrackCreateEvent implements HyperPermsEvent, Cancellable {

/**
* The state of the track creation event.
*/
public enum State {
/**
* Before the track is created. The event can be cancelled at this point.
*/
PRE,

/**
* After the track has been created. Cancellation has no effect.
*/
POST
}

private final String trackName;
private final Track track;
private final State state;
private boolean cancelled;

/**
* Creates a PRE event for track creation.
*
* @param trackName the name of the track being created
*/
public TrackCreateEvent(@NotNull String trackName) {
this.trackName = trackName;
this.track = null;
this.state = State.PRE;
this.cancelled = false;
}

/**
* Creates a POST event for track creation.
*
* @param track the created track
*/
public TrackCreateEvent(@NotNull Track track) {
this.trackName = track.getName();
this.track = track;
this.state = State.POST;
this.cancelled = false;
}

@Override
public EventType getType() {
return EventType.TRACK_CREATE;
}

/**
* Gets the name of the track being created.
*
* @return the track name
*/
@NotNull
public String getTrackName() {
return trackName;
}

/**
* Gets the created track.
* <p>
* This is only available in the POST state. Returns null in PRE state.
*
* @return the track, or null if PRE state
*/
@Nullable
public Track getTrack() {
return track;
}

/**
* Gets the state of this event.
*
* @return the state
*/
@NotNull
public State getState() {
return state;
}

@Override
public boolean isCancelled() {
return cancelled;
}

@Override
public void setCancelled(boolean cancelled) {
if (state == State.PRE) {
this.cancelled = cancelled;
}
}

@Override
public String toString() {
return "TrackCreateEvent{trackName='" + trackName + "', state=" + state + ", cancelled=" + cancelled + "}";
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ data/
# Temp
*.log
.claude/
.worktrees/
CLAUDE.md
.serena/
.playwright-mcp/
Expand Down
233 changes: 233 additions & 0 deletions docs/plans/2026-02-22-realtime-websocket-editor-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
# Realtime WebSocket Editor Design

**Date:** 2026-02-22
**Status:** Approved
**Scope:** HyperPerms plugin, HyperPermsWeb editor, new CF Workers relay

## Problem

The web editor at hyperperms.com uses a polling/REST model: the plugin POSTs session
data to the API, the user edits in the browser, then runs `/hp apply <session>` to pull
changes back. This is slow, manual, and one-directional.

## Goal

Two-way realtime sync between the browser editor and the Hytale plugin via WebSocket,
so edits flow in both directions without manual commands.

## Decisions

| Decision | Choice | Rationale |
|---|---|---|
| Direction | Two-way full sync | Editor pushes to server, server pushes to editor |
| Transport | WebSocket via Cloudflare Durable Objects | Already on CF Workers, no extra vendor, ~$5/month |
| Relay model | API-side (CF DO) | Both plugin and browser connect as WS clients to CF |
| Default apply mode | Batch with confirm | Safety: changes accumulate, user clicks Apply |
| Live mode | Toggle in editor UI | Opt-in immediate apply for power users |
| Undo | Server-side undo history (last 20 ops) | Reversible batch applies |
| Message format | Incremental diffs (operations) | Small payloads, natural undo, efficient |
| Conflict handling | Auto-merge non-conflicting, UI for conflicts | Server changes to different entities merge silently |

## Architecture

```
Browser (Next.js) CF Durable Object Hytale Plugin (Java)
| (1 DO per session) |
|--- WS connect -------->| |
| |<-------- WS connect ---------------|
| | |
|-- editor.change ------->| (stores op in pending list) |
| | |
|-- batch.apply --------->| |
| |--- batch.apply ------------------->|
| | [applies to live server]
| |<--- apply.result ------------------|
|<-- apply.result --------| |
| | |
| |<--- server.change -----------------|
|<-- server.change -------| [in-game command ran]
```

### Session Lifecycle

1. Player runs `/hp editor` in-game
2. Plugin creates session via existing REST API (POST /api/session/create)
3. API returns session ID + editor URL (unchanged)
4. Plugin opens WebSocket to `wss://ws.hyperperms.com/session/<id>` as `plugin` role
5. Player opens editor in browser, editor opens WebSocket to same URL as `editor` role
6. DO has both connections -- relay begins
7. On disconnect/timeout, DO hibernates (cost-efficient)
8. Session expires after 24h (same as current TTL)

### Why CF Durable Objects

- CF Workers already in use (no new vendor)
- DOs can hold persistent WebSocket connections (unlike Vercel serverless)
- Built-in hibernation API saves costs when idle
- Native WebSocket support with `acceptWebSocket()` API
- Cost: ~$0.15/M requests + $0.50/GB-month -- effectively free at our volume
- Stays within Cloudflare ecosystem

## Message Protocol

### Operation Types

All messages are JSON with a `type` field. Operations are the atomic unit of change.

```typescript
// === Editor -> DO -> Plugin ===

// Individual changes (accumulated in batch mode, applied immediately in live mode)
{ type: "op", op: "permission.add", target: "group:admin", node: "server.kick", value: true }
{ type: "op", op: "permission.remove", target: "group:admin", node: "server.kick" }
{ type: "op", op: "permission.set", target: "user:<uuid>", node: "chat.color", value: false }
{ type: "op", op: "group.create", name: "moderator", weight: 50, parents: ["default"] }
{ type: "op", op: "group.delete", name: "moderator" }
{ type: "op", op: "group.setMeta", target: "admin", key: "prefix", value: "&c[Admin] " }
{ type: "op", op: "group.setWeight", target: "admin", weight: 100 }
{ type: "op", op: "group.addParent", target: "moderator", parent: "default" }
{ type: "op", op: "group.removeParent", target: "moderator", parent: "default" }
{ type: "op", op: "user.addGroup", target: "<uuid>", group: "admin" }
{ type: "op", op: "user.removeGroup", target: "<uuid>", group: "admin" }
{ type: "op", op: "track.create", name: "staff", groups: ["default", "helper", "mod", "admin"] }
{ type: "op", op: "track.delete", name: "staff" }
{ type: "op", op: "track.addGroup", target: "staff", group: "moderator", position: 2 }
{ type: "op", op: "track.removeGroup", target: "staff", group: "moderator" }

// Batch apply (batch mode only)
{ type: "batch.apply" }

// Mode switch
{ type: "session.mode", mode: "live" | "batch" }

// === Plugin -> DO -> Editor ===

// Server-side change (in-game command, API, another plugin)
{ type: "server.change", op: "permission.add", target: "group:admin", node: "fly.use",
value: true, source: "console", timestamp: 1708617600000 }

// Apply result
{ type: "apply.result", success: true, applied: 5, failed: 0,
errors: [], undoId: "abc123" }

// Undo result
{ type: "undo.result", success: true, undoId: "abc123", reverted: 5 }

// === Control messages (both directions) ===

{ type: "session.sync", data: { groups: [...], users: [...], tracks: [...] } }
{ type: "session.ping" }
{ type: "session.pong" }
{ type: "session.error", code: "CONFLICT", message: "...", details: {...} }
```

### Operation Inversion (for undo)

Each operation has a natural inverse stored in the undo history:

| Operation | Inverse |
|---|---|
| `permission.add` | `permission.remove` (same target/node) |
| `permission.remove` | `permission.add` (with original value) |
| `group.create` | `group.delete` |
| `group.delete` | `group.create` (with full group data snapshot) |
| `group.setWeight` | `group.setWeight` (with previous weight) |
| `user.addGroup` | `user.removeGroup` |
| `user.removeGroup` | `user.addGroup` |
| `track.addGroup` | `track.removeGroup` |

## Conflict Detection

### Non-conflicting (auto-merge)

Server change to entity A while editor has pending changes to entity B:
- Apply server change to editor state silently
- Show subtle indicator that server state updated (e.g. pulse animation on the changed entity)

### Conflicting

Server change to entity A while editor has pending changes to entity A:
- Mark the entity with a conflict indicator in the UI
- Show conflict banner: "Server changed [group: admin] -- your local changes may conflict"
- Options: "Keep mine", "Accept server", "View diff"
- Conflict state clears when user resolves

### Detection Logic

Conflict = same `target` value in both a pending editor op and an incoming server change.
Tracked per-entity, not per-field (simple and predictable).

## Undo History

- Stored in the Durable Object's transactional storage
- Last 20 batch applies, each with its inverse operations
- Each undo entry: `{ undoId, timestamp, ops: Operation[], inverseOps: Operation[] }`
- Undo request: `{ type: "undo", undoId: "abc123" }` -> sends inverse ops to plugin
- History clears on session expiry

## Three Codebases

### 1. CF Workers (new Durable Object)

**Location:** New worker in existing CF project or standalone
**Responsibilities:**
- Accept WebSocket connections from editor and plugin
- Route messages between them based on type
- Store pending operations (batch mode)
- Maintain undo history
- Handle hibernation for cost efficiency
- Session authentication (validate session ID against Upstash Redis)

**Key files:**
- `src/session-do.ts` -- Durable Object class with WebSocket handlers
- `src/index.ts` -- Worker entry point, routes `/session/:id` to DO
- `wrangler.toml` -- DO binding configuration

### 2. HyperPermsWeb (Next.js editor)

**Changes:**
- New WebSocket hook: `useRealtimeSession(sessionId)` -- manages WS connection, reconnection, message handling
- Editor state: track pending operations, conflict state
- UI: "Live" toggle switch, Apply button (batch mode), conflict banners, undo button
- Connection status indicator (connected/reconnecting/disconnected)
- Replace current `PUT /api/session` save flow with WebSocket ops

**Key files:**
- `src/hooks/useRealtimeSession.ts` -- WebSocket connection + state management
- `src/components/editor/RealtimeStatus.tsx` -- Connection indicator
- `src/components/editor/ConflictBanner.tsx` -- Conflict resolution UI
- `src/components/editor/LiveModeToggle.tsx` -- Live/batch toggle
- Modifications to existing editor components to dispatch ops instead of direct state mutation

### 3. HyperPerms (Java plugin)

**Changes:**
- New `WebSocketClient` class using Java 11+ `HttpClient` WebSocket API
- Change listener: hook into existing EventBus to detect in-game permission changes
- Apply handler: receive ops from editor, apply them through existing manager layer
- Undo support: store pre-apply snapshots for revert
- Auto-reconnect on connection loss
- New config options: `webEditor.realtimeEnabled`, `webEditor.wsUrl`

**Key files:**
- `src/main/java/com/hyperperms/web/RealtimeClient.java` -- WebSocket client
- `src/main/java/com/hyperperms/web/RealtimeMessageHandler.java` -- Message routing
- `src/main/java/com/hyperperms/web/OperationApplier.java` -- Applies ops to live state
- `src/main/java/com/hyperperms/web/ChangeListener.java` -- Detects in-game changes, sends to WS
- Modifications to `WebEditorService.java` -- Start WS after session creation

## Security

- Session ID serves as auth token (same as current model)
- DO validates session exists in Redis before accepting WS upgrade
- Plugin identifies as `role: plugin` on connect, editor as `role: editor`
- Only one plugin connection per session (reject duplicates)
- Rate limiting on operations (prevent abuse from editor)
- All traffic over WSS (TLS)

## Migration / Backwards Compatibility

- Existing REST flow continues to work (no breaking changes)
- WebSocket is additive -- if WS connection fails, editor falls back to REST save/load
- Plugin config: `webEditor.realtimeEnabled: true` (default true for new installs)
- `/hp apply` command still works as fallback
7 changes: 6 additions & 1 deletion src/main/java/com/hyperperms/HyperPerms.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -217,7 +217,7 @@ public void enable() {

// Initialize managers with event bus
groupManager = new GroupManagerImpl(storage, cacheInvalidator, eventBus);
trackManager = new TrackManagerImpl(storage);
trackManager = new TrackManagerImpl(storage, eventBus);
userManager = new UserManagerImpl(storage, cache, eventBus, config.getDefaultGroup());

// Load data
Expand DownExpand Up@@ -428,6 +428,11 @@ public void disable() {
placeholderApiIntegration.unregister();
}

// Disconnect WebSocket before shutting down scheduler
if (webEditorService != null) {
webEditorService.disconnectWebSocket();
}

// Stop scheduled tasks FIRST to prevent new storage executor submissions
if (expiryTask != null) {
expiryTask.cancel(true);
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/com/hyperperms/api/events/HyperPermsEvent.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,21 @@ enum EventType {
*/
DATA_RELOAD,

/**
* Fired when a track is created.
*/
TRACK_CREATE,

/**
* Fired when a track is deleted.
*/
TRACK_DELETE,

/**
* Fired when a track is modified.
*/
TRACK_MODIFY,

/**
* Fired when a user is promoted along a track.
*/
Expand Down
112 changes: 112 additions & 0 deletions src/main/java/com/hyperperms/api/events/TrackCreateEvent.java
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
package com.hyperperms.api.events;

import com.hyperperms.model.Track;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Event fired when a track is created.
* <p>
* This event can be cancelled to prevent the track from being created.
* The PRE state fires before creation, the POST state fires after.
*/
public final class TrackCreateEvent implements HyperPermsEvent, Cancellable {

/**
* The state of the track creation event.
*/
public enum State {
/**
* Before the track is created. The event can be cancelled at this point.
*/
PRE,

/**
* After the track has been created. Cancellation has no effect.
*/
POST
}

private final String trackName;
private final Track track;
private final State state;
private boolean cancelled;

/**
* Creates a PRE event for track creation.
*
* @param trackName the name of the track being created
*/
public TrackCreateEvent(@NotNull String trackName) {
this.trackName = trackName;
this.track = null;
this.state = State.PRE;
this.cancelled = false;
}

/**
* Creates a POST event for track creation.
*
* @param track the created track
*/
public TrackCreateEvent(@NotNull Track track) {
this.trackName = track.getName();
this.track = track;
this.state = State.POST;
this.cancelled = false;
}

@Override
public EventType getType() {
return EventType.TRACK_CREATE;
}

/**
* Gets the name of the track being created.
*
* @return the track name
*/
@NotNull
public String getTrackName() {
return trackName;
}

/**
* Gets the created track.
* <p>
* This is only available in the POST state. Returns null in PRE state.
*
* @return the track, or null if PRE state
*/
@Nullable
public Track getTrack() {
return track;
}

/**
* Gets the state of this event.
*
* @return the state
*/
@NotNull
public State getState() {
return state;
}

@Override
public boolean isCancelled() {
return cancelled;
}

@Override
public void setCancelled(boolean cancelled) {
if (state == State.PRE) {
this.cancelled = cancelled;
}
}

@Override
public String toString() {
return "TrackCreateEvent{trackName='" + trackName + "', state=" + state + ", cancelled=" + cancelled + "}";
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ data/
# Temp
*.log
.claude/
.worktrees/
CLAUDE.md
.serena/
.playwright-mcp/
Expand Down
233 changes: 233 additions & 0 deletions docs/plans/2026-02-22-realtime-websocket-editor-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
# Realtime WebSocket Editor Design

**Date:** 2026-02-22
**Status:** Approved
**Scope:** HyperPerms plugin, HyperPermsWeb editor, new CF Workers relay

## Problem

The web editor at hyperperms.com uses a polling/REST model: the plugin POSTs session
data to the API, the user edits in the browser, then runs `/hp apply <session>` to pull
changes back. This is slow, manual, and one-directional.

## Goal

Two-way realtime sync between the browser editor and the Hytale plugin via WebSocket,
so edits flow in both directions without manual commands.

## Decisions

| Decision | Choice | Rationale |
|---|---|---|
| Direction | Two-way full sync | Editor pushes to server, server pushes to editor |
| Transport | WebSocket via Cloudflare Durable Objects | Already on CF Workers, no extra vendor, ~$5/month |
| Relay model | API-side (CF DO) | Both plugin and browser connect as WS clients to CF |
| Default apply mode | Batch with confirm | Safety: changes accumulate, user clicks Apply |
| Live mode | Toggle in editor UI | Opt-in immediate apply for power users |
| Undo | Server-side undo history (last 20 ops) | Reversible batch applies |
| Message format | Incremental diffs (operations) | Small payloads, natural undo, efficient |
| Conflict handling | Auto-merge non-conflicting, UI for conflicts | Server changes to different entities merge silently |

## Architecture

```
Browser (Next.js) CF Durable Object Hytale Plugin (Java)
| (1 DO per session) |
|--- WS connect -------->| |
| |<-------- WS connect ---------------|
| | |
|-- editor.change ------->| (stores op in pending list) |
| | |
|-- batch.apply --------->| |
| |--- batch.apply ------------------->|
| | [applies to live server]
| |<--- apply.result ------------------|
|<-- apply.result --------| |
| | |
| |<--- server.change -----------------|
|<-- server.change -------| [in-game command ran]
```

### Session Lifecycle

1. Player runs `/hp editor` in-game
2. Plugin creates session via existing REST API (POST /api/session/create)
3. API returns session ID + editor URL (unchanged)
4. Plugin opens WebSocket to `wss://ws.hyperperms.com/session/<id>` as `plugin` role
5. Player opens editor in browser, editor opens WebSocket to same URL as `editor` role
6. DO has both connections -- relay begins
7. On disconnect/timeout, DO hibernates (cost-efficient)
8. Session expires after 24h (same as current TTL)

### Why CF Durable Objects

- CF Workers already in use (no new vendor)
- DOs can hold persistent WebSocket connections (unlike Vercel serverless)
- Built-in hibernation API saves costs when idle
- Native WebSocket support with `acceptWebSocket()` API
- Cost: ~$0.15/M requests + $0.50/GB-month -- effectively free at our volume
- Stays within Cloudflare ecosystem

## Message Protocol

### Operation Types

All messages are JSON with a `type` field. Operations are the atomic unit of change.

```typescript
// === Editor -> DO -> Plugin ===

// Individual changes (accumulated in batch mode, applied immediately in live mode)
{ type: "op", op: "permission.add", target: "group:admin", node: "server.kick", value: true }
{ type: "op", op: "permission.remove", target: "group:admin", node: "server.kick" }
{ type: "op", op: "permission.set", target: "user:<uuid>", node: "chat.color", value: false }
{ type: "op", op: "group.create", name: "moderator", weight: 50, parents: ["default"] }
{ type: "op", op: "group.delete", name: "moderator" }
{ type: "op", op: "group.setMeta", target: "admin", key: "prefix", value: "&c[Admin] " }
{ type: "op", op: "group.setWeight", target: "admin", weight: 100 }
{ type: "op", op: "group.addParent", target: "moderator", parent: "default" }
{ type: "op", op: "group.removeParent", target: "moderator", parent: "default" }
{ type: "op", op: "user.addGroup", target: "<uuid>", group: "admin" }
{ type: "op", op: "user.removeGroup", target: "<uuid>", group: "admin" }
{ type: "op", op: "track.create", name: "staff", groups: ["default", "helper", "mod", "admin"] }
{ type: "op", op: "track.delete", name: "staff" }
{ type: "op", op: "track.addGroup", target: "staff", group: "moderator", position: 2 }
{ type: "op", op: "track.removeGroup", target: "staff", group: "moderator" }

// Batch apply (batch mode only)
{ type: "batch.apply" }

// Mode switch
{ type: "session.mode", mode: "live" | "batch" }

// === Plugin -> DO -> Editor ===

// Server-side change (in-game command, API, another plugin)
{ type: "server.change", op: "permission.add", target: "group:admin", node: "fly.use",
value: true, source: "console", timestamp: 1708617600000 }

// Apply result
{ type: "apply.result", success: true, applied: 5, failed: 0,
errors: [], undoId: "abc123" }

// Undo result
{ type: "undo.result", success: true, undoId: "abc123", reverted: 5 }

// === Control messages (both directions) ===

{ type: "session.sync", data: { groups: [...], users: [...], tracks: [...] } }
{ type: "session.ping" }
{ type: "session.pong" }
{ type: "session.error", code: "CONFLICT", message: "...", details: {...} }
```

### Operation Inversion (for undo)

Each operation has a natural inverse stored in the undo history:

| Operation | Inverse |
|---|---|
| `permission.add` | `permission.remove` (same target/node) |
| `permission.remove` | `permission.add` (with original value) |
| `group.create` | `group.delete` |
| `group.delete` | `group.create` (with full group data snapshot) |
| `group.setWeight` | `group.setWeight` (with previous weight) |
| `user.addGroup` | `user.removeGroup` |
| `user.removeGroup` | `user.addGroup` |
| `track.addGroup` | `track.removeGroup` |

## Conflict Detection

### Non-conflicting (auto-merge)

Server change to entity A while editor has pending changes to entity B:
- Apply server change to editor state silently
- Show subtle indicator that server state updated (e.g. pulse animation on the changed entity)

### Conflicting

Server change to entity A while editor has pending changes to entity A:
- Mark the entity with a conflict indicator in the UI
- Show conflict banner: "Server changed [group: admin] -- your local changes may conflict"
- Options: "Keep mine", "Accept server", "View diff"
- Conflict state clears when user resolves

### Detection Logic

Conflict = same `target` value in both a pending editor op and an incoming server change.
Tracked per-entity, not per-field (simple and predictable).

## Undo History

- Stored in the Durable Object's transactional storage
- Last 20 batch applies, each with its inverse operations
- Each undo entry: `{ undoId, timestamp, ops: Operation[], inverseOps: Operation[] }`
- Undo request: `{ type: "undo", undoId: "abc123" }` -> sends inverse ops to plugin
- History clears on session expiry

## Three Codebases

### 1. CF Workers (new Durable Object)

**Location:** New worker in existing CF project or standalone
**Responsibilities:**
- Accept WebSocket connections from editor and plugin
- Route messages between them based on type
- Store pending operations (batch mode)
- Maintain undo history
- Handle hibernation for cost efficiency
- Session authentication (validate session ID against Upstash Redis)

**Key files:**
- `src/session-do.ts` -- Durable Object class with WebSocket handlers
- `src/index.ts` -- Worker entry point, routes `/session/:id` to DO
- `wrangler.toml` -- DO binding configuration

### 2. HyperPermsWeb (Next.js editor)

**Changes:**
- New WebSocket hook: `useRealtimeSession(sessionId)` -- manages WS connection, reconnection, message handling
- Editor state: track pending operations, conflict state
- UI: "Live" toggle switch, Apply button (batch mode), conflict banners, undo button
- Connection status indicator (connected/reconnecting/disconnected)
- Replace current `PUT /api/session` save flow with WebSocket ops

**Key files:**
- `src/hooks/useRealtimeSession.ts` -- WebSocket connection + state management
- `src/components/editor/RealtimeStatus.tsx` -- Connection indicator
- `src/components/editor/ConflictBanner.tsx` -- Conflict resolution UI
- `src/components/editor/LiveModeToggle.tsx` -- Live/batch toggle
- Modifications to existing editor components to dispatch ops instead of direct state mutation

### 3. HyperPerms (Java plugin)

**Changes:**
- New `WebSocketClient` class using Java 11+ `HttpClient` WebSocket API
- Change listener: hook into existing EventBus to detect in-game permission changes
- Apply handler: receive ops from editor, apply them through existing manager layer
- Undo support: store pre-apply snapshots for revert
- Auto-reconnect on connection loss
- New config options: `webEditor.realtimeEnabled`, `webEditor.wsUrl`

**Key files:**
- `src/main/java/com/hyperperms/web/RealtimeClient.java` -- WebSocket client
- `src/main/java/com/hyperperms/web/RealtimeMessageHandler.java` -- Message routing
- `src/main/java/com/hyperperms/web/OperationApplier.java` -- Applies ops to live state
- `src/main/java/com/hyperperms/web/ChangeListener.java` -- Detects in-game changes, sends to WS
- Modifications to `WebEditorService.java` -- Start WS after session creation

## Security

- Session ID serves as auth token (same as current model)
- DO validates session exists in Redis before accepting WS upgrade
- Plugin identifies as `role: plugin` on connect, editor as `role: editor`
- Only one plugin connection per session (reject duplicates)
- Rate limiting on operations (prevent abuse from editor)
- All traffic over WSS (TLS)

## Migration / Backwards Compatibility

- Existing REST flow continues to work (no breaking changes)
- WebSocket is additive -- if WS connection fails, editor falls back to REST save/load
- Plugin config: `webEditor.realtimeEnabled: true` (default true for new installs)
- `/hp apply` command still works as fallback
7 changes: 6 additions & 1 deletion src/main/java/com/hyperperms/HyperPerms.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -217,7 +217,7 @@ public void enable() {

// Initialize managers with event bus
groupManager = new GroupManagerImpl(storage, cacheInvalidator, eventBus);
trackManager = new TrackManagerImpl(storage);
trackManager = new TrackManagerImpl(storage, eventBus);
userManager = new UserManagerImpl(storage, cache, eventBus, config.getDefaultGroup());

// Load data
Expand DownExpand Up@@ -428,6 +428,11 @@ public void disable() {
placeholderApiIntegration.unregister();
}

// Disconnect WebSocket before shutting down scheduler
if (webEditorService != null) {
webEditorService.disconnectWebSocket();
}

// Stop scheduled tasks FIRST to prevent new storage executor submissions
if (expiryTask != null) {
expiryTask.cancel(true);
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/com/hyperperms/api/events/HyperPermsEvent.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,21 @@ enum EventType {
*/
DATA_RELOAD,

/**
* Fired when a track is created.
*/
TRACK_CREATE,

/**
* Fired when a track is deleted.
*/
TRACK_DELETE,

/**
* Fired when a track is modified.
*/
TRACK_MODIFY,

/**
* Fired when a user is promoted along a track.
*/
Expand Down
112 changes: 112 additions & 0 deletions src/main/java/com/hyperperms/api/events/TrackCreateEvent.java
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
package com.hyperperms.api.events;

import com.hyperperms.model.Track;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Event fired when a track is created.
* <p>
* This event can be cancelled to prevent the track from being created.
* The PRE state fires before creation, the POST state fires after.
*/
public final class TrackCreateEvent implements HyperPermsEvent, Cancellable {

/**
* The state of the track creation event.
*/
public enum State {
/**
* Before the track is created. The event can be cancelled at this point.
*/
PRE,

/**
* After the track has been created. Cancellation has no effect.
*/
POST
}

private final String trackName;
private final Track track;
private final State state;
private boolean cancelled;

/**
* Creates a PRE event for track creation.
*
* @param trackName the name of the track being created
*/
public TrackCreateEvent(@NotNull String trackName) {
this.trackName = trackName;
this.track = null;
this.state = State.PRE;
this.cancelled = false;
}

/**
* Creates a POST event for track creation.
*
* @param track the created track
*/
public TrackCreateEvent(@NotNull Track track) {
this.trackName = track.getName();
this.track = track;
this.state = State.POST;
this.cancelled = false;
}

@Override
public EventType getType() {
return EventType.TRACK_CREATE;
}

/**
* Gets the name of the track being created.
*
* @return the track name
*/
@NotNull
public String getTrackName() {
return trackName;
}

/**
* Gets the created track.
* <p>
* This is only available in the POST state. Returns null in PRE state.
*
* @return the track, or null if PRE state
*/
@Nullable
public Track getTrack() {
return track;
}

/**
* Gets the state of this event.
*
* @return the state
*/
@NotNull
public State getState() {
return state;
}

@Override
public boolean isCancelled() {
return cancelled;
}

@Override
public void setCancelled(boolean cancelled) {
if (state == State.PRE) {
this.cancelled = cancelled;
}
}

@Override
public String toString() {
return "TrackCreateEvent{trackName='" + trackName + "', state=" + state + ", cancelled=" + cancelled + "}";
}
}
Loading