Skip to content

Commit 030b23d

Browse files
committed
fix(editor): never fail an operation because its announcement could not go out
1 parent 6b25682 commit 030b23d

7 files changed

Lines changed: 177 additions & 14 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ REVERB_SCHEME=https
6666

6767
Start the server with `php artisan reverb:start` and give it a route from the public host. Behind Cloudflare, WebSockets must be enabled for the zone.
6868

69+
Reverb needs **two** paths proxied to it, not one:
70+
71+
| Path | Used by | Direction |
72+
|---|---|---|
73+
| `/app/{key}` | browser and plugin | the WebSocket itself |
74+
| `/apps/{id}/events` | this application | publishing an event |
75+
76+
Routing only `/app` leaves the sockets working while every publish fails. Nothing breaks visibly, because a failed announcement is logged rather than raised, but the other open windows stop hearing about changes.
77+
6978
## Demo mode
7079

7180
Opening the site without a session shows the editor running on the example menus the plugin ships. Everything renders and every editor works; only saving is unavailable, because there is no server behind it.

app/Http/Controllers/Api/MenuController.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
use App\Services\RpcBridge;
1010
use Illuminate\Http\JsonResponse;
1111
use Illuminate\Http\Request;
12+
use Illuminate\Support\Facades\Log;
1213
use Illuminate\Validation\Rule;
14+
use Throwable;
1315

1416
/**
1517
* Menu operations, each one forwarded to the plugin that owns the session.
@@ -64,9 +66,22 @@ private function forward(Request $request, RpcBridge $rpc, RpcAction $action, ar
6466
return response()->json($rpc->call($session->server, $action, $payload));
6567
}
6668

69+
/**
70+
* Lets the other windows know a menu changed.
71+
*
72+
* Broadcasting is a courtesy: the file is already written, so a broadcaster
73+
* that is down or misrouted must not turn a successful save into a 500.
74+
*/
6775
private function announce(Request $request, string $platform, string $fileName, string $change): void
6876
{
69-
MenuChanged::dispatch($this->session($request), $platform, $fileName, $change);
77+
try {
78+
MenuChanged::dispatch($this->session($request), $platform, $fileName, $change);
79+
} catch (Throwable $failure) {
80+
Log::warning('Could not announce a menu change', [
81+
'fileName' => $fileName,
82+
'reason' => $failure->getMessage(),
83+
]);
84+
}
7085
}
7186

7287
private function session(Request $request): EditorSession

app/Http/Controllers/Api/Plugin/HeartbeatController.php

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
use App\Services\RealtimeConfig;
1010
use Illuminate\Http\JsonResponse;
1111
use Illuminate\Http\Request;
12+
use Illuminate\Support\Facades\Log;
13+
use Throwable;
1214

1315
/**
1416
* Keeps the server marked as reachable and tells open windows when it comes back.
@@ -40,10 +42,18 @@ public function __invoke(Request $request, RealtimeConfig $realtime): JsonRespon
4042
]);
4143
}
4244

45+
/**
46+
* A heartbeat keeps the server marked reachable whether or not the open
47+
* windows can be told about it, so a broken broadcaster is only logged.
48+
*/
4349
private function announceReturn(Server $server): void
4450
{
45-
$server->editorSessions()
46-
->alive()
47-
->each(fn (EditorSession $session) => PluginStatusChanged::dispatch($session, true));
51+
try {
52+
$server->editorSessions()
53+
->alive()
54+
->each(fn (EditorSession $session) => PluginStatusChanged::dispatch($session, true));
55+
} catch (Throwable $failure) {
56+
Log::warning('Could not announce that a server came back', ['reason' => $failure->getMessage()]);
57+
}
4858
}
4959
}

app/Services/RpcBridge.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
use App\Exceptions\ServerOfflineException;
88
use App\Models\Server;
99
use Illuminate\Support\Facades\Cache;
10+
use Illuminate\Support\Facades\Log;
1011
use Illuminate\Support\Str;
12+
use Throwable;
1113

1214
/**
1315
* Request/response bridge between a browser request and the plugin.
@@ -37,7 +39,14 @@ public function call(Server $server, RpcAction $action, array $payload = []): ar
3739
if ($server->uses_polling) {
3840
$this->enqueue($server, $requestId, $action, $payload);
3941
} else {
40-
RpcRequested::dispatch($server, $requestId, $action, $payload);
42+
try {
43+
RpcRequested::dispatch($server, $requestId, $action, $payload);
44+
} catch (Throwable $failure) {
45+
// The channel is the fast path, not the only one. Queue the
46+
// request so the plugin still finds it on its next poll.
47+
Log::warning('Could not publish an RPC request, queueing it', ['reason' => $failure->getMessage()]);
48+
$this->enqueue($server, $requestId, $action, $payload);
49+
}
4150
}
4251

4352
return $this->awaitResponse($requestId);

resources/js/api/client.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,24 @@ export class ApiError extends Error {
2020
}
2121
}
2222

23-
function csrfToken(): string {
24-
return document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
23+
/**
24+
* Validating a session regenerates it, which rotates the CSRF token and leaves
25+
* the one rendered into the page stale. The cookie is rewritten on every
26+
* response, so it is the only value that stays current.
27+
*/
28+
export function csrfHeaders(): Record<string, string> {
29+
const cookie = document.cookie
30+
.split('; ')
31+
.find(entry => entry.startsWith('XSRF-TOKEN='))
32+
?.slice('XSRF-TOKEN='.length);
33+
34+
if (cookie !== undefined && cookie !== '') {
35+
return { 'X-XSRF-TOKEN': decodeURIComponent(cookie) };
36+
}
37+
38+
const meta = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content;
39+
40+
return meta === undefined ? {} : { 'X-CSRF-TOKEN': meta };
2541
}
2642

2743
async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
@@ -30,7 +46,7 @@ async function request<T>(method: string, url: string, body?: unknown): Promise<
3046
headers: {
3147
Accept: 'application/json',
3248
'X-Requested-With': 'XMLHttpRequest',
33-
'X-CSRF-TOKEN': csrfToken(),
49+
...csrfHeaders(),
3450
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
3551
},
3652
body: body === undefined ? undefined : JSON.stringify(body),

resources/js/components/AdminTerminal.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState } from 'react';
2-
import { ApiError } from '../api/client';
2+
import { ApiError, csrfHeaders } from '../api/client';
33

44
interface CommandResult {
55
success: boolean;
@@ -30,7 +30,7 @@ export function AdminTerminal({ onClose }: { onClose: () => void }) {
3030
headers: {
3131
Accept: 'application/json',
3232
'Content-Type': 'application/json',
33-
'X-CSRF-TOKEN': csrfToken(),
33+
...csrfHeaders(),
3434
'X-Admin-Token': token,
3535
},
3636
body: JSON.stringify({ command }),
@@ -125,10 +125,6 @@ function render(result: CommandResult): string[] {
125125
return lines;
126126
}
127127

128-
function csrfToken(): string {
129-
return document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
130-
}
131-
132128
function readToken(): string {
133129
try {
134130
return sessionStorage.getItem(TOKEN_STORAGE_KEY) ?? '';
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Enums\RpcAction;
6+
use App\Models\EditorSession;
7+
use App\Models\Server;
8+
use App\Services\RpcBridge;
9+
use Database\Factories\ServerFactory;
10+
use Illuminate\Foundation\Testing\RefreshDatabase;
11+
use Tests\TestCase;
12+
13+
/**
14+
* A broadcaster that cannot be reached is an infrastructure problem. It must
15+
* never turn an operation that already succeeded into a failure.
16+
*/
17+
class BroadcastFailureTest extends TestCase
18+
{
19+
use RefreshDatabase;
20+
21+
protected function setUp(): void
22+
{
23+
parent::setUp();
24+
25+
// Point the broadcaster at a port nothing listens on.
26+
config([
27+
'broadcasting.default' => 'reverb',
28+
'broadcasting.connections.reverb.key' => 'key',
29+
'broadcasting.connections.reverb.secret' => 'secret',
30+
'broadcasting.connections.reverb.app_id' => '1',
31+
'broadcasting.connections.reverb.options' => [
32+
'host' => '127.0.0.1',
33+
'port' => 1,
34+
'scheme' => 'http',
35+
'useTLS' => false,
36+
],
37+
]);
38+
}
39+
40+
public function test_a_save_still_succeeds_when_the_change_cannot_be_announced(): void
41+
{
42+
$session = $this->readySession();
43+
$this->withAnsweringPlugin();
44+
45+
$this->actingAs($session, 'editor')
46+
->postJson(route('api.menus.store'), [
47+
'platform' => 'java',
48+
'fileName' => 'shop.yml',
49+
'content' => 'menu: {}',
50+
])
51+
->assertOk();
52+
}
53+
54+
public function test_a_delete_still_succeeds_when_the_change_cannot_be_announced(): void
55+
{
56+
$session = $this->readySession();
57+
$this->withAnsweringPlugin();
58+
59+
$this->actingAs($session, 'editor')
60+
->deleteJson(route('api.menus.destroy'), ['platform' => 'java', 'fileName' => 'shop.yml'])
61+
->assertOk();
62+
}
63+
64+
public function test_a_heartbeat_still_succeeds_when_the_return_cannot_be_announced(): void
65+
{
66+
$server = Server::factory()->offline()->create();
67+
EditorSession::factory()->create(['server_id' => $server->id]);
68+
69+
$this->withHeaders(['X-Server-Uuid' => $server->uuid, 'X-Server-Token' => ServerFactory::TOKEN])
70+
->postJson(route('plugin.heartbeat'))
71+
->assertOk();
72+
73+
$this->assertTrue($server->refresh()->isOnline());
74+
}
75+
76+
public function test_a_request_that_cannot_be_published_is_queued_for_the_next_poll(): void
77+
{
78+
config(['editor.rpc.timeout' => 0.2, 'editor.rpc.poll_interval_ms' => 20]);
79+
$bridge = app(RpcBridge::class);
80+
$server = Server::factory()->onChannel()->create();
81+
82+
rescue(fn () => $bridge->call($server, RpcAction::MenuList));
83+
84+
$this->assertCount(1, $bridge->collectPending($server));
85+
}
86+
87+
private function readySession(): EditorSession
88+
{
89+
return EditorSession::factory()
90+
->for(Server::factory())
91+
->create(['confirmed' => true, 'consumed' => true, 'active' => false]);
92+
}
93+
94+
/**
95+
* A plugin that always answers, so the test isolates what it is about: the
96+
* response of an operation whose broadcast could not go out.
97+
*/
98+
private function withAnsweringPlugin(): void
99+
{
100+
$this->app->instance(RpcBridge::class, new class extends RpcBridge
101+
{
102+
public function call(Server $server, RpcAction $action, array $payload = []): array
103+
{
104+
return ['ok' => true, 'payload' => [], 'error' => null];
105+
}
106+
});
107+
}
108+
}

0 commit comments

Comments
 (0)