Merge origin/main and remove migrated asset browser

This commit is contained in:
Matt Isenhower
2026-09-13 12:31:52 -07:00
6 changed files with 1 additions and 961 deletions

View File

@@ -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",

View File

@@ -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'],
},
});

View File

@@ -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.

View File

@@ -1,631 +0,0 @@
const INTERNAL_PREFIX = '/__directory';
const PAGE_SIZE = 1000;
/** @param {string} value */
function escapeHtml(value) {
return value.replace(/[&<>"']/g, character => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
'\u0027': '&#39;',
})[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 '<span aria-current="page">assets</span>';
}
let crumbs = ['<a href="/">assets</a>'];
for (let index = 0; index < parts.length; index++) {
crumbs.push('<span class="breadcrumb-separator" aria-hidden="true">/</span>');
if (index === parts.length - 1) {
crumbs.push(`<span aria-current="page">${escapeHtml(parts[index])}</span>`);
} else {
let path = publicPath(`${parts.slice(0, index + 1).join('/')}/`);
crumbs.push(`<a href="${path}">${escapeHtml(parts[index])}</a>`);
}
}
return crumbs.join('');
}
/** @param {ReturnType<typeof directoryPage>} page */
function renderListing(page) {
let rows = [];
if (page.prefix) {
rows.push(`<li><a class="listing-row parent-row" href="${parentPath(page.prefix)}"><span class="listing-cell entry"><span class="visually-hidden">Name: </span><span class="entry-icon entry-icon--parent" aria-hidden="true">↑</span><span class="entry-name">Parent directory</span></span><span class="listing-cell"></span><span class="listing-cell"></span></a></li>`);
}
for (let directory of page.directories) {
rows.push(`<li><a class="listing-row" href="${directory.pathname}"><span class="listing-cell entry"><span class="visually-hidden">Name: </span><span class="entry-icon entry-icon--folder" aria-hidden="true"></span><span class="entry-name">${escapeHtml(directory.name)}</span></span><span class="listing-cell"></span><span class="listing-cell"><span class="visually-hidden">Type: </span><span class="type-label">Folder</span></span></a></li>`);
}
for (let file of page.files) {
rows.push(`<li><a class="listing-row" href="${file.pathname}"><span class="listing-cell entry"><span class="visually-hidden">Name: </span><span class="entry-icon entry-icon--file" aria-hidden="true"></span><span class="entry-name">${escapeHtml(file.name)}</span></span><span class="listing-cell"><span class="visually-hidden">Last modified: </span><time datetime="${file.uploaded.toISOString()}">${formatDate(file.uploaded)}</time></span><span class="listing-cell"><span class="visually-hidden">Size: </span>${formatSize(file.size)}</span></a></li>`);
}
if (!rows.length) {
rows.push('<li class="empty-state">This directory is empty.</li>');
}
let jsonCursor = page.requestedCursor
? `&amp;cursor=${encodeURIComponent(page.requestedCursor)}`
: '';
let next = page.nextCursor
? `<a class="next-page" rel="next" href="${page.pathname}?cursor=${encodeURIComponent(page.nextCursor)}">Next page <span aria-hidden="true">→</span></a>`
: '';
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: `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<link rel="canonical" href="${page.url}">
<link rel="alternate" type="application/json" href="${page.pathname}?format=json${jsonCursor}">
<title>${escapeHtml(title)}</title>
<style nonce="${nonce}">
:root {
color-scheme: light dark;
--background: #f3f5f9;
--background-accent: #e8e4ff;
--surface: rgba(255, 255, 255, 0.88);
--surface-strong: #fff;
--text: #18202f;
--muted: #657084;
--border: #dce1ea;
--accent: #6047d9;
--accent-soft: #eeeaff;
--folder: #8a6cff;
--file: #8290a6;
--row-hover: #f7f5ff;
--shadow: 0 24px 70px rgba(38, 29, 82, 0.12);
}
* { box-sizing: border-box; }
body {
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at 15% 0%, var(--background-accent), transparent 34rem),
var(--background);
color: var(--text);
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.5;
}
a { color: inherit; }
.shell {
width: min(960px, calc(100% - 40px));
margin: 0 auto;
padding: 48px 0;
}
.browser {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--surface);
box-shadow: var(--shadow);
backdrop-filter: blur(14px);
}
.browser-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-height: 68px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: var(--surface-strong);
}
.breadcrumbs {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
overflow: hidden;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.9rem;
white-space: nowrap;
}
.breadcrumbs a {
color: var(--accent);
font-weight: 700;
text-decoration: none;
}
.breadcrumbs a:hover { text-decoration: underline; }
.breadcrumbs [aria-current="page"] { overflow: hidden; text-overflow: ellipsis; }
.breadcrumb-separator { color: var(--muted); }
.summary { color: var(--muted); font-size: 0.82rem; }
.browser-meta {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 12px;
}
.json-link {
border-radius: 999px;
padding: 3px 9px;
background: var(--accent-soft);
color: var(--accent);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.72rem;
font-weight: 750;
text-decoration: none;
}
.json-link:hover { text-decoration: underline; }
.listing-wrap { overflow-x: auto; }
.listing {
min-width: 520px;
width: 100%;
font-size: 0.9rem;
}
.listing-items { margin: 0; padding: 0; list-style: none; }
.listing-header, .listing-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 210px 110px;
}
.listing-header > span {
padding: 12px 20px;
color: var(--muted);
font-size: 0.7rem;
font-weight: 750;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.listing-row {
min-height: 56px;
border-top: 1px solid var(--border);
color: var(--muted);
text-decoration: none;
transition: background-color 120ms ease;
}
.listing-row:hover { background: var(--row-hover); }
.listing-row:focus-visible { border-radius: 4px; outline: 2px solid var(--accent); outline-offset: -3px; }
.listing-cell {
display: flex;
min-width: 0;
align-items: center;
padding: 8px 20px;
white-space: nowrap;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.entry {
max-width: min(560px, 58vw);
gap: 12px;
color: var(--text);
font-weight: 620;
}
.entry-name { overflow: hidden; text-overflow: ellipsis; }
.listing-row:hover .entry-name { color: var(--accent); text-decoration: underline; }
.entry-icon {
position: relative;
display: inline-block;
flex: 0 0 18px;
width: 18px;
height: 18px;
}
.entry-icon--folder {
height: 13px;
margin-top: 3px;
border-radius: 3px;
background: var(--folder);
}
.entry-icon--folder::before {
position: absolute;
top: -4px;
left: 1px;
width: 8px;
height: 5px;
border-radius: 3px 3px 0 0;
background: var(--folder);
content: "";
}
.entry-icon--file { border: 2px solid var(--file); border-radius: 3px; }
.entry-icon--file::after {
position: absolute;
right: 2px;
bottom: 3px;
left: 2px;
height: 2px;
background: var(--file);
box-shadow: 0 -4px 0 var(--file);
content: "";
}
.entry-icon--parent {
display: grid;
place-items: center;
border-radius: 5px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.85rem;
font-weight: 800;
}
.parent-row .entry { color: var(--muted); font-weight: 560; }
.type-label {
display: inline-block;
border-radius: 999px;
padding: 2px 8px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.72rem;
font-weight: 700;
}
.empty-state {
display: grid;
height: 120px;
place-items: center;
border-top: 1px solid var(--border);
text-align: center;
}
.browser-footer {
display: flex;
justify-content: flex-end;
padding: 14px 20px;
border-top: 1px solid var(--border);
background: var(--surface-strong);
}
.browser-footer:empty { display: none; }
.next-page { color: var(--accent); font-size: 0.86rem; font-weight: 700; text-decoration: none; }
.next-page:hover { text-decoration: underline; }
@media (prefers-color-scheme: dark) {
:root {
--background: #0b0e15;
--background-accent: #201a43;
--surface: rgba(20, 24, 35, 0.9);
--surface-strong: #151923;
--text: #f3f4f8;
--muted: #9ba4b5;
--border: #2b3240;
--accent: #a997ff;
--accent-soft: #292443;
--folder: #9b87ff;
--file: #8590a4;
--row-hover: #1d2030;
--shadow: 0 28px 80px rgba(0, 0, 0, 0.4);
}
}
@media (max-width: 640px) {
.shell { width: min(100% - 24px, 960px); padding: 24px 0; }
.browser-header { align-items: flex-start; flex-direction: column; gap: 6px; }
.browser-meta { width: 100%; justify-content: space-between; }
.listing-header, .listing-row { grid-template-columns: minmax(0, 1fr) 190px 92px; }
.listing-header > span, .listing-cell { padding-right: 14px; padding-left: 14px; }
.entry { max-width: calc(100vw - 150px); }
}
</style>
</head>
<body>
<main class="shell">
<section class="browser" aria-label="Directory contents">
<div class="browser-header">
<nav class="breadcrumbs" aria-label="Breadcrumb">${breadcrumbHtml(page.prefix)}</nav>
<div class="browser-meta"><span class="summary">${summary}</span><a class="json-link" href="${page.pathname}?format=json${jsonCursor}">JSON</a></div>
</div>
<div class="listing-wrap">
<div class="listing">
<div class="listing-header"><span>Name</span><span>Last modified</span><span>Size</span></div>
<ul class="listing-items" aria-label="Directory entries">${rows.join('')}</ul>
</div>
</div>
<div class="browser-footer">${next}</div>
</section>
</main>
</body>
</html>`,
nonce,
};
}
/**
* @param {Request} request
* @param {ReturnType<typeof directoryPage>} 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',
},
});
},
};

View File

@@ -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('<main class="shell">');
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('<link rel="canonical" href="https://assets.splatoon2.ink/">');
expect(html).toContain('href="/odd%3C%26%22.json"');
expect(html).toContain('odd&lt;&amp;&quot;.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);
});
});

View File

@@ -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
}
}
}