From 9c872a217a3809492417ec50e7ae20e312ae0c5e Mon Sep 17 00:00:00 2001 From: phyce Date: Fri, 31 Jul 2026 08:49:03 +0000 Subject: [PATCH] Strip unread fields from the shared plugin payload Metric: total byte weight / FCP (~272 KB uncompressed off every page) `plugins` is a shared Inertia prop, so the full 2048-plugin list is embedded in the HTML of every page, not just the homepage. Measured on production, it dominates the payload everywhere: /naturalspeech 925 KB JSON, 907 KB plugins (98%) /developers/growing 938 KB JSON, 907 KB plugins (97%) /top 969 KB JSON, 907 KB plugins (94%) /developers 1,095 KB JSON, 907 KB plugins (83%) Only two consumers read that list: the homepage table and the header search box. Between them they never touch `git_repo`, `support` or `created_on`, which cost 133 KB, 62 KB and 80 KB respectively - 29.6% of the list. Those three fields are read elsewhere, which is why they cannot simply be dropped from the API client: PluginDetail reads all three off its own single-plugin prop, and TopAbsolute/TopRelative read `created_on` off `entry.plugin`. Both come from different endpoints and are untouched here. The trim is applied only where the list is handed to the client. GenerateSitemap consumes the same array but only reads `name` and `updated_on`, both retained. Co-Authored-By: Claude Opus 5 --- app/Http/Controllers/PluginController.php | 2 +- app/Http/Middleware/HandleInertiaRequests.php | 2 +- app/Services/RuneliteApiService.php | 37 +++++++++ tests/Feature/ClientPluginPayloadTest.php | 75 +++++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/ClientPluginPayloadTest.php diff --git a/app/Http/Controllers/PluginController.php b/app/Http/Controllers/PluginController.php index 5e01a32..3a96312 100644 --- a/app/Http/Controllers/PluginController.php +++ b/app/Http/Controllers/PluginController.php @@ -19,7 +19,7 @@ public function __construct(private RuneliteApiService $runeliteApi) {} public function index(Request $request): Response { $params = $request->only(['range']); - $plugins = $this->runeliteApi->getPlugins($params); + $plugins = $this->runeliteApi->getClientPlugins($params); SEOTools::setTitle('RuneLite Plugin Stats — Browse All Plugins'); SEOTools::setDescription('Browse install counts, all-time highs for every RuneLite plugins.'); diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 0c77b22..b56808c 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -43,7 +43,7 @@ public function share(Request $request): array 'name' => config('app.name'), 'apiUrl' => rtrim(config('services.runelite_api.client_url'), '/'), 'appUrl' => config('app.url'), - 'plugins' => $this->runeliteApi->getPlugins([]), + 'plugins' => $this->runeliteApi->getClientPlugins([]), ]; } } diff --git a/app/Services/RuneliteApiService.php b/app/Services/RuneliteApiService.php index d4774d6..6c6154e 100644 --- a/app/Services/RuneliteApiService.php +++ b/app/Services/RuneliteApiService.php @@ -12,6 +12,26 @@ class RuneliteApiService /** @var array{0: array>, 1: array>}|null */ private ?array $developerLookups = null; + /** + * Fields the browser actually reads off the shared plugin list: the homepage + * table and the header search. This list is embedded in every Inertia + * response, so anything the client never touches is dead weight on the wire. + * + * @var list + */ + private const CLIENT_PLUGIN_FIELDS = [ + 'id', + 'name', + 'display', + 'author', + 'description', + 'tags', + 'warning', + 'updated_on', + 'all_time_high', + 'current_installs', + ]; + public function __construct() { $this->baseUrl = rtrim(config('services.runelite_api.url'), '/'); @@ -25,6 +45,23 @@ public function getPlugins(array $params = []): array }); } + /** + * The plugin list as it is handed to the client, stripped to the fields the + * frontend reads. `git_repo`, `support` and `created_on` are only ever read + * off the single-plugin and ranking-entry props, never off this list. + * + * @return array + */ + public function getClientPlugins(array $params = []): array + { + $keep = array_flip(self::CLIENT_PLUGIN_FIELDS); + + return array_map( + fn (array $plugin): array => array_intersect_key($plugin, $keep), + $this->getPlugins($params), + ); + } + /** @return array|null */ public function getPlugin(string $name, array $params = []): ?array { diff --git a/tests/Feature/ClientPluginPayloadTest.php b/tests/Feature/ClientPluginPayloadTest.php new file mode 100644 index 0000000..cb3d4b3 --- /dev/null +++ b/tests/Feature/ClientPluginPayloadTest.php @@ -0,0 +1,75 @@ +fakeRuneliteApi(); + + $plugins = $this->sharedPlugins(); + + $this->assertCount(1, $plugins); + + foreach (['git_repo', 'support', 'created_on'] as $dropped) { + $this->assertArrayNotHasKey($dropped, $plugins[0]); + } + } + + public function test_it_keeps_every_field_the_table_and_search_read(): void + { + $this->fakeRuneliteApi(); + + $plugins = $this->sharedPlugins(); + + foreach ([ + 'id', 'name', 'display', 'author', 'description', 'tags', + 'warning', 'updated_on', 'all_time_high', 'current_installs', + ] as $kept) { + $this->assertArrayHasKey($kept, $plugins[0]); + } + } + + /** @return array> */ + private function sharedPlugins(): array + { + $response = $this->get(route('home')); + + $response->assertOk(); + + return $response->viewData('page')['props']['plugins']; + } + + private function fakeRuneliteApi(): void + { + $plugin = [ + 'id' => 1, + 'name' => 'example-plugin', + 'git_repo' => 'https://github.com/example/example-plugin.git', + 'display' => 'Example Plugin', + 'author' => 'Example Author', + 'support' => 'https://discord.gg/example', + 'description' => 'An example plugin.', + 'tags' => 'example,test', + 'warning' => '', + 'created_on' => '2024-01-01T00:00:00.000Z', + 'updated_on' => '2024-06-01T00:00:00.000Z', + 'all_time_high' => 200, + 'current_installs' => 100, + ]; + + Http::fake([ + '*/plugins' => Http::response(['success' => true, 'data' => [$plugin]]), + '*/developers' => Http::response(['success' => true, 'data' => ['developers' => []]]), + '*' => Http::response(['success' => true, 'data' => []]), + ]); + } +}