diff --git a/package.json b/package.json
index 13b7182..56e1c87 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,7 @@
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build --modern --no-clean",
- "test": "node --test && npm run worker:test && npm run updater:test",
+ "test": "node --test && npm run updater:test",
"lint": "eslint .",
"cron": "node src/app/cron.js",
"start": "npm run splatnet && npm run social && npm run cron",
@@ -27,10 +27,6 @@
"utility:copyTranslation": "node src/utility/index.js copyTranslation",
"utility:getSplatNetLanguageFiles": "node src/utility/index.js getSplatNetLanguageFiles",
"utility:updateGear": "node src/utility/index.js updateGear",
- "worker:dev": "wrangler dev --local --var LOCAL_DEVELOPMENT:true --persist-to .wrangler/state",
- "worker:test": "vitest run --config vitest.worker.config.mjs",
- "worker:deploy:dry-run": "wrangler deploy --dry-run",
- "worker:deploy": "wrangler deploy",
"lint-fix": "npm run lint -- --fix",
"updater:dev": "wrangler dev --config workers/updater/wrangler.jsonc --env dev --test-scheduled",
"updater:test": "vitest run --config workers/updater/vitest.config.mjs",
diff --git a/vitest.worker.config.mjs b/vitest.worker.config.mjs
deleted file mode 100644
index 6f6a265..0000000
--- a/vitest.worker.config.mjs
+++ /dev/null
@@ -1,15 +0,0 @@
-import { cloudflareTest } from '@cloudflare/vitest-plugin';
-import { defineConfig } from 'vitest/config';
-
-export default defineConfig({
- plugins: [
- cloudflareTest({
- wrangler: {
- configPath: './wrangler.jsonc',
- },
- }),
- ],
- test: {
- include: ['worker/**/*.spec.mjs'],
- },
-});
diff --git a/worker/README.md b/worker/README.md
deleted file mode 100644
index e9d5635..0000000
--- a/worker/README.md
+++ /dev/null
@@ -1,74 +0,0 @@
-# Splatoon 2 app Worker
-
-This Worker is the home for edge behavior on the Splatoon 2 domains. Its first
-feature is a read-only directory index for the public R2 bucket. Ordinary object
-requests continue to use the R2 custom domain directly and do not invoke the
-Worker.
-
-## Directory index
-
-The browser-visible directory URLs end in `/`, for example:
-
-- `https://assets.splatoon2.ink/`
-- `https://assets.splatoon2.ink/data/`
-- `https://assets.splatoon2.ink/screenshots/`
-
-Each directory also has a non-recursive JSON representation at the same public
-URL by adding `?format=json`. Clients must follow the absolute `next` URL when
-present because the R2 cursor is opaque.
-
-The production Worker route is intentionally limited to:
-
-```text
-assets.splatoon2.ink/__directory/*
-```
-
-The internal prefix never appears in links or canonical URLs returned by the
-Worker.
-
-## Local development
-
-Run:
-
-```sh
-npm run worker:dev
-```
-
-Then open `http://localhost:8787/`. The local-development flag lets Wrangler
-serve natural directory paths and linked objects from its simulated R2 bucket.
-It does not connect to the production bucket.
-
-Validate the Worker without deploying:
-
-```sh
-npm run worker:test
-npm run worker:deploy:dry-run
-```
-
-`npm run worker:deploy` deploys the Worker and must only be run as part of an
-intentional production rollout.
-
-## Production rollout
-
-1. Deploy the Worker.
-2. Verify a direct internal URL such as
- `https://assets.splatoon2.ink/__directory/data/`.
-3. In the `splatoon2.ink` zone, create a Cloudflare **URL Rewrite Rule** with
- this filter expression:
-
- ```text
- (http.host eq "assets.splatoon2.ink" and ends_with(http.request.uri.path, "/") and not starts_with(http.request.uri.path, "/__directory/"))
- ```
-
-4. Configure a dynamic path rewrite with this expression:
-
- ```text
- concat("/__directory", http.request.uri.path)
- ```
-
-5. Leave the query string unchanged, deploy the rule, and verify the natural
- root and nested directory URLs in both HTML and JSON formats.
-
-The rewrite runs only for paths ending in `/`; JSON files, calendars, images,
-and screenshots remain direct R2 requests. To roll back, disable the URL Rewrite
-Rule first, then remove the Worker route or deployment.
diff --git a/worker/index.js b/worker/index.js
deleted file mode 100644
index ddea0a4..0000000
--- a/worker/index.js
+++ /dev/null
@@ -1,631 +0,0 @@
-const INTERNAL_PREFIX = '/__directory';
-const PAGE_SIZE = 1000;
-
-/** @param {string} value */
-function escapeHtml(value) {
- return value.replace(/[&<>"']/g, character => ({
- '&': '&',
- '<': '<',
- '>': '>',
- '"': '"',
- '\u0027': ''',
- })[character]);
-}
-
-/** @param {string} key */
-function publicPath(key) {
- return `/${key.split('/').map(encodeURIComponent).join('/')}`;
-}
-
-/** @param {string} prefix */
-function parentPath(prefix) {
- let parts = prefix.split('/').filter(Boolean);
- parts.pop();
- return parts.length ? publicPath(`${parts.join('/')}/`) : '/';
-}
-
-/** @param {number} bytes */
-function formatSize(bytes) {
- if (bytes < 1024) {
- return `${bytes} B`;
- }
-
- let value = bytes;
- let unit = 'B';
- for (let candidate of ['KB', 'MB', 'GB', 'TB']) {
- value /= 1024;
- unit = candidate;
- if (value < 1024) {
- break;
- }
- }
- return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`;
-}
-
-/** @param {Date} date */
-function formatDate(date) {
- return `${date.toISOString().slice(0, 19).replace('T', ' ')} UTC`;
-}
-
-/**
- * @param {Request} request
- * @param {unknown} value
- * @param {number} [status]
- * @param {HeadersInit} [headers]
- */
-function jsonResponse(request, value, status = 200, headers = {}) {
- let body = JSON.stringify(value);
- return new Response(request.method === 'HEAD' ? null : body, {
- status,
- headers: {
- 'Access-Control-Allow-Origin': '*',
- 'Cache-Control': 'no-store',
- 'Content-Type': 'application/json; charset=utf-8',
- 'X-Content-Type-Options': 'nosniff',
- ...headers,
- },
- });
-}
-
-/**
- * @param {Request} request
- * @param {number} status
- * @param {string} message
- * @param {HeadersInit} [headers]
- */
-function textErrorResponse(request, status, message, headers = {}) {
- return new Response(request.method === 'HEAD' ? null : message, {
- status,
- headers: {
- 'Cache-Control': 'no-store',
- 'Content-Type': 'text/plain; charset=utf-8',
- 'X-Content-Type-Options': 'nosniff',
- ...headers,
- },
- });
-}
-
-/**
- * @param {Request} request
- * @param {number} status
- * @param {string} code
- * @param {string} message
- * @param {HeadersInit} [headers]
- */
-function errorResponse(request, status, code, message, headers = {}) {
- if (new URL(request.url).searchParams.get('format') !== 'json') {
- return textErrorResponse(request, status, message, headers);
- }
-
- return jsonErrorResponse(request, status, code, message, headers);
-}
-
-/**
- * @param {Request} request
- * @param {number} status
- * @param {string} code
- * @param {string} message
- * @param {HeadersInit} [headers]
- */
-function jsonErrorResponse(request, status, code, message, headers = {}) {
- return jsonResponse(request, {
- schemaVersion: 1,
- error: { code, message },
- }, status, headers);
-}
-
-/**
- * @param {string} origin
- * @param {string} prefix
- * @param {R2Objects} listing
- * @param {string | undefined} requestedCursor
- */
-function directoryPage(origin, prefix, listing, requestedCursor) {
- let pathname = publicPath(prefix);
- return {
- directories: listing.delimitedPrefixes.map(directoryPrefix => ({
- name: directoryPrefix.slice(prefix.length),
- pathname: publicPath(directoryPrefix),
- prefix: directoryPrefix,
- url: new URL(publicPath(directoryPrefix), origin).href,
- })),
- files: listing.objects.map(object => ({
- etag: object.etag,
- key: object.key,
- name: object.key.slice(prefix.length),
- pathname: publicPath(object.key),
- size: object.size,
- uploaded: object.uploaded,
- url: new URL(publicPath(object.key), origin).href,
- })),
- nextCursor: listing.truncated ? listing.cursor : undefined,
- pathname,
- prefix,
- requestedCursor,
- url: new URL(pathname, origin).href,
- };
-}
-
-/** @param {string} prefix */
-function breadcrumbHtml(prefix) {
- let parts = prefix.split('/').filter(Boolean);
- if (!parts.length) {
- return 'assets';
- }
-
- let crumbs = ['assets'];
- for (let index = 0; index < parts.length; index++) {
- crumbs.push('/');
- if (index === parts.length - 1) {
- crumbs.push(`${escapeHtml(parts[index])}`);
- } else {
- let path = publicPath(`${parts.slice(0, index + 1).join('/')}/`);
- crumbs.push(`${escapeHtml(parts[index])}`);
- }
- }
- return crumbs.join('');
-}
-
-/** @param {ReturnType} page */
-function renderListing(page) {
- let rows = [];
- if (page.prefix) {
- rows.push(`Name: โParent directory`);
- }
- for (let directory of page.directories) {
- rows.push(`Name: ${escapeHtml(directory.name)}Type: Folder`);
- }
- for (let file of page.files) {
- rows.push(`Name: ${escapeHtml(file.name)}Last modified: Size: ${formatSize(file.size)}`);
- }
- if (!rows.length) {
- rows.push('This directory is empty.');
- }
-
- let jsonCursor = page.requestedCursor
- ? `&cursor=${encodeURIComponent(page.requestedCursor)}`
- : '';
- let next = page.nextCursor
- ? `Next page โ`
- : '';
- let title = `Asset browser โ ${page.pathname}`;
- let nonce = crypto.randomUUID().replaceAll('-', '');
- let folderCount = page.directories.length;
- let fileCount = page.files.length;
- let summary = `${folderCount} ${folderCount === 1 ? 'folder' : 'folders'} ยท ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`;
-
- return {
- body: `
-
-
-
-
-
-
-
- ${escapeHtml(title)}
-
-
-
-
-
-
-
-`,
- nonce,
- };
-}
-
-/**
- * @param {Request} request
- * @param {ReturnType} page
- */
-function jsonListingResponse(request, page) {
- let next = page.nextCursor
- ? new URL(`${page.pathname}?format=json&cursor=${encodeURIComponent(page.nextCursor)}`, page.url).href
- : null;
- return jsonResponse(request, {
- schemaVersion: 1,
- directory: {
- prefix: page.prefix,
- url: page.url,
- },
- directories: page.directories.map(directory => ({
- name: directory.name,
- prefix: directory.prefix,
- url: directory.url,
- })),
- files: page.files.map(file => ({
- name: file.name,
- key: file.key,
- url: file.url,
- size: file.size,
- uploaded: file.uploaded.toISOString(),
- etag: file.etag,
- })),
- next,
- });
-}
-
-/**
- * @param {Request} request
- * @param {R2Bucket} bucket
- * @param {string} key
- */
-async function localObjectResponse(request, bucket, key) {
- let object = await bucket.get(key);
- if (!object) {
- return textErrorResponse(request, 404, 'Object not found');
- }
-
- let headers = new Headers;
- object.writeHttpMetadata(headers);
- headers.set('Cache-Control', 'no-store');
- headers.set('ETag', object.httpEtag);
- return new Response(request.method === 'HEAD' ? null : object.body, { headers });
-}
-
-export default {
- async fetch(request, env) {
- let url = new URL(request.url);
- let localDevelopment = env.LOCAL_DEVELOPMENT === 'true';
-
- if (!['GET', 'HEAD'].includes(request.method)) {
- return errorResponse(request, 405, 'method_not_allowed', 'Method not allowed', {
- Allow: 'GET, HEAD',
- });
- }
-
- let encodedPath;
- if (url.pathname.startsWith(`${INTERNAL_PREFIX}/`)) {
- encodedPath = url.pathname.slice(INTERNAL_PREFIX.length + 1);
- } else if (localDevelopment && url.pathname.endsWith('/')) {
- encodedPath = url.pathname.slice(1);
- } else if (localDevelopment) {
- let key;
- try {
- key = decodeURIComponent(url.pathname.slice(1));
- } catch {
- return errorResponse(request, 400, 'invalid_path', 'Invalid path');
- }
- return localObjectResponse(request, env.ASSETS, key);
- } else {
- return errorResponse(request, 404, 'not_found', 'Not found');
- }
-
- let prefix;
- try {
- prefix = decodeURIComponent(encodedPath);
- } catch {
- return errorResponse(request, 400, 'invalid_path', 'Invalid path');
- }
-
- let format = url.searchParams.get('format');
- if (format && format !== 'json') {
- return jsonErrorResponse(request, 400, 'unsupported_format', 'Unsupported format');
- }
-
- let cursor = url.searchParams.get('cursor') || undefined;
- let listing;
- try {
- listing = await env.ASSETS.list({
- cursor,
- delimiter: '/',
- limit: PAGE_SIZE,
- prefix,
- });
- } catch (error) {
- console.error(JSON.stringify({
- error: error instanceof Error ? error.message : String(error),
- message: 'R2 directory listing failed',
- prefix,
- }));
- return errorResponse(request, 500, 'listing_unavailable', 'Directory listing unavailable');
- }
-
- if (prefix && !listing.objects.length && !listing.delimitedPrefixes.length) {
- return errorResponse(request, 404, 'directory_not_found', 'Directory not found');
- }
-
- let page = directoryPage(url.origin, prefix, listing, cursor);
- if (format === 'json') {
- return jsonListingResponse(request, page);
- }
-
- let listingHtml = renderListing(page);
- return new Response(request.method === 'HEAD' ? null : listingHtml.body, {
- headers: {
- 'Cache-Control': 'no-store',
- 'Content-Security-Policy': `default-src 'none'; style-src 'nonce-${listingHtml.nonce}'; base-uri 'none'; frame-ancestors 'none'`,
- 'Content-Type': 'text/html; charset=utf-8',
- 'X-Content-Type-Options': 'nosniff',
- 'X-Robots-Tag': 'noindex',
- },
- });
- },
-};
diff --git a/worker/index.spec.mjs b/worker/index.spec.mjs
deleted file mode 100644
index c2df229..0000000
--- a/worker/index.spec.mjs
+++ /dev/null
@@ -1,205 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import worker from './index.js';
-
-class FakeBucket
-{
- calls = [];
-
- constructor(result = {}) {
- this.result = {
- cursor: undefined,
- delimitedPrefixes: ['data/locale/'],
- objects: [{
- etag: 'opaque-etag',
- key: 'data/schedules.json',
- size: 54861,
- uploaded: new Date('2026-08-30T17:30:26Z'),
- }],
- truncated: false,
- ...result,
- };
- }
-
- async list(options) {
- this.calls.push(options);
- return this.result;
- }
-}
-
-describe('R2 directory index', () => {
- it('renders a directory listing through the internal route with public links', async () => {
- let bucket = new FakeBucket;
- let response = await worker.fetch(new Request(
- 'https://assets.splatoon2.ink/__directory/data/',
- ), { ASSETS: bucket });
- let html = await response.text();
-
- expect(response.status).toBe(200);
- expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8');
- expect(response.headers.get('cache-control')).toBe('no-store');
- expect(html).toContain('');
- expect(html).toContain('class="entry-icon entry-icon--folder"');
- expect(html).toContain('class="entry-icon entry-icon--file"');
- expect(html).toContain('class="listing-header"');
- expect(html).toContain('href="/data/locale/"');
- expect(html).toContain('href="/data/schedules.json"');
- expect(html).not.toContain('__directory');
- expect(bucket.calls).toEqual([{
- cursor: undefined,
- delimiter: '/',
- limit: 1000,
- prefix: 'data/',
- }]);
- });
-
- it('returns paginated JSON with encoded canonical URLs', async () => {
- let bucket = new FakeBucket({
- cursor: 'next page',
- delimitedPrefixes: ['screenshots/Octo Expansion/bosses/'],
- objects: [{
- etag: 'etag-value',
- key: 'screenshots/Octo Expansion/odd<&".png',
- size: 1536,
- uploaded: new Date('2026-08-31T01:02:03Z'),
- }],
- truncated: true,
- });
- let response = await worker.fetch(new Request(
- 'https://assets.splatoon2.ink/__directory/screenshots/Octo%20Expansion/?format=json&cursor=current%20page',
- ), { ASSETS: bucket });
-
- expect(response.status).toBe(200);
- expect(response.headers.get('access-control-allow-origin')).toBe('*');
- expect(await response.json()).toEqual({
- schemaVersion: 1,
- directory: {
- prefix: 'screenshots/Octo Expansion/',
- url: 'https://assets.splatoon2.ink/screenshots/Octo%20Expansion/',
- },
- directories: [{
- name: 'bosses/',
- prefix: 'screenshots/Octo Expansion/bosses/',
- url: 'https://assets.splatoon2.ink/screenshots/Octo%20Expansion/bosses/',
- }],
- files: [{
- name: 'odd<&".png',
- key: 'screenshots/Octo Expansion/odd<&".png',
- url: 'https://assets.splatoon2.ink/screenshots/Octo%20Expansion/odd%3C%26%22.png',
- size: 1536,
- uploaded: '2026-08-31T01:02:03.000Z',
- etag: 'etag-value',
- }],
- next: 'https://assets.splatoon2.ink/screenshots/Octo%20Expansion/?format=json&cursor=next%20page',
- });
- expect(bucket.calls).toEqual([{
- cursor: 'current page',
- delimiter: '/',
- limit: 1000,
- prefix: 'screenshots/Octo Expansion/',
- }]);
- });
-
- it('escapes object names and preserves pagination in HTML', async () => {
- let bucket = new FakeBucket({
- cursor: 'opaque cursor',
- delimitedPrefixes: [],
- objects: [{
- key: 'odd<&".json',
- size: 1536,
- uploaded: new Date('2026-08-30T17:30:26Z'),
- }],
- truncated: true,
- });
- let response = await worker.fetch(
- new Request('https://assets.splatoon2.ink/__directory/'),
- { ASSETS: bucket },
- );
- let html = await response.text();
-
- expect(html).toContain('');
- expect(html).toContain('href="/odd%3C%26%22.json"');
- expect(html).toContain('odd<&".json');
- expect(html).toContain('1.5 KB');
- expect(html).toContain('2026-08-30 17:30:26 UTC');
- expect(html).toContain('href="/?cursor=opaque%20cursor"');
- });
-
- it('supports HEAD and rejects other methods before reading R2', async () => {
- let headBucket = new FakeBucket;
- let headResponse = await worker.fetch(new Request(
- 'https://assets.splatoon2.ink/__directory/data/?format=json',
- { method: 'HEAD' },
- ), { ASSETS: headBucket });
- let postBucket = new FakeBucket;
- let postResponse = await worker.fetch(new Request(
- 'https://assets.splatoon2.ink/__directory/data/',
- { method: 'POST' },
- ), { ASSETS: postBucket });
-
- expect(headResponse.status).toBe(200);
- expect(headResponse.headers.get('content-type')).toBe('application/json; charset=utf-8');
- expect(await headResponse.text()).toBe('');
- expect(headBucket.calls).toHaveLength(1);
- expect(postResponse.status).toBe(405);
- expect(postResponse.headers.get('allow')).toBe('GET, HEAD');
- expect(await postResponse.text()).toBe('Method not allowed');
- expect(postBucket.calls).toHaveLength(0);
- });
-
- it('returns a controlled machine-readable response when R2 fails', async () => {
- let errorLog = vi.spyOn(console, 'error').mockImplementation(() => {});
- let bucket = {
- list: vi.fn().mockRejectedValue(new Error('R2 unavailable')),
- };
- let response = await worker.fetch(new Request(
- 'https://assets.splatoon2.ink/__directory/data/?format=json',
- ), { ASSETS: bucket });
-
- expect(response.status).toBe(500);
- expect(response.headers.get('cache-control')).toBe('no-store');
- expect(await response.json()).toEqual({
- schemaVersion: 1,
- error: {
- code: 'listing_unavailable',
- message: 'Directory listing unavailable',
- },
- });
- expect(errorLog).toHaveBeenCalledWith(JSON.stringify({
- error: 'R2 unavailable',
- message: 'R2 directory listing failed',
- prefix: 'data/',
- }));
- errorLog.mockRestore();
- });
-
- it('returns controlled errors for invalid paths, formats, and missing directories', async () => {
- let bucket = new FakeBucket({
- delimitedPrefixes: [],
- objects: [],
- });
-
- let invalidPath = await worker.fetch(new Request(
- 'https://assets.splatoon2.ink/__directory/%E0%A4%A',
- ), { ASSETS: bucket });
- let unsupportedFormat = await worker.fetch(new Request(
- 'https://assets.splatoon2.ink/__directory/data/?format=xml',
- ), { ASSETS: bucket });
- let missingDirectory = await worker.fetch(new Request(
- 'https://assets.splatoon2.ink/__directory/missing/',
- ), { ASSETS: bucket });
-
- expect(invalidPath.status).toBe(400);
- expect(await invalidPath.text()).toBe('Invalid path');
- expect(unsupportedFormat.status).toBe(400);
- expect(await unsupportedFormat.json()).toEqual({
- schemaVersion: 1,
- error: {
- code: 'unsupported_format',
- message: 'Unsupported format',
- },
- });
- expect(missingDirectory.status).toBe(404);
- expect(await missingDirectory.text()).toBe('Directory not found');
- expect(bucket.calls).toHaveLength(1);
- });
-});
diff --git a/wrangler.jsonc b/wrangler.jsonc
deleted file mode 100644
index 9a69aa7..0000000
--- a/wrangler.jsonc
+++ /dev/null
@@ -1,31 +0,0 @@
-{
- "$schema": "./node_modules/wrangler/config-schema.json",
- "name": "splatoon2-ink-app",
- "main": "worker/index.js",
- "compatibility_date": "2026-09-02",
- "compatibility_flags": ["nodejs_compat"],
- "workers_dev": false,
- "preview_urls": false,
- "routes": [
- {
- "pattern": "assets.splatoon2.ink/__directory/*",
- "zone_name": "splatoon2.ink"
- }
- ],
- "r2_buckets": [
- {
- "binding": "ASSETS",
- "bucket_name": "splatoon2-ink-assets"
- }
- ],
- "observability": {
- "enabled": true,
- "logs": {
- "head_sampling_rate": 1
- },
- "traces": {
- "enabled": true,
- "head_sampling_rate": 0.01
- }
- }
-}