mirror of
https://github.com/misenhower/splatoon3.ink.git
synced 2026-09-28 18:16:25 -05:00
Merge pull request #119 from misenhower/codex/assets-directory-browser
Some checks failed
Update query hashes / check-and-create-pr (push) Has been cancelled
Build frontend / build (22.x) (push) Has been cancelled
Deploy / deploy-frontend (push) Has been cancelled
Deploy / deploy-backend (push) Has been cancelled
Lint / lint (push) Has been cancelled
Tests / test (22.x) (push) Has been cancelled
Some checks failed
Update query hashes / check-and-create-pr (push) Has been cancelled
Build frontend / build (22.x) (push) Has been cancelled
Deploy / deploy-frontend (push) Has been cancelled
Deploy / deploy-backend (push) Has been cancelled
Lint / lint (push) Has been cancelled
Tests / test (22.x) (push) Has been cancelled
Add R2 asset directory browser
This commit is contained in:
1
.github/workflows/build-frontend.yml
vendored
1
.github/workflows/build-frontend.yml
vendored
@@ -25,3 +25,4 @@ jobs:
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm run assets-browser:deploy:dry-run
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,6 +13,7 @@ dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
.wrangler
|
||||
|
||||
.env
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -53,9 +53,24 @@ export default [
|
||||
},
|
||||
},
|
||||
|
||||
// Cloudflare runtime types referenced by Worker JSDoc.
|
||||
{
|
||||
files: ['workers/**/*.js'],
|
||||
rules: {
|
||||
'jsdoc/no-undefined-types': ['warn', { definedTypes: [
|
||||
'Env',
|
||||
'ExportedHandler',
|
||||
'HeadersInit',
|
||||
'R2Bucket',
|
||||
'R2Objects',
|
||||
] }],
|
||||
},
|
||||
},
|
||||
|
||||
// Ignored files
|
||||
{
|
||||
ignores: [
|
||||
'**/.wrangler/**',
|
||||
'dist/**',
|
||||
'src/assets/i18n/index.mjs',
|
||||
],
|
||||
|
||||
1480
package-lock.json
generated
1480
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -4,10 +4,14 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"assets-browser:dev": "wrangler dev --local --var LOCAL_DEVELOPMENT:true --persist-to .wrangler/state --cwd workers/assets-directory-browser",
|
||||
"assets-browser:test": "vitest run --config workers/assets-directory-browser/vitest.config.mjs",
|
||||
"assets-browser:deploy": "wrangler deploy --cwd workers/assets-directory-browser",
|
||||
"assets-browser:deploy:dry-run": "wrangler deploy --dry-run --cwd workers/assets-directory-browser",
|
||||
"preview": "vite preview --port 5050",
|
||||
"lint": "eslint .",
|
||||
"lint-fix": "npm run lint -- --fix",
|
||||
"test": "vitest run",
|
||||
"test": "vitest run && npm run assets-browser:test",
|
||||
"test:watch": "vitest",
|
||||
"cron": "node app/index.mjs cron",
|
||||
"start": "npm run sync:download && npm run splatnet:quick && npm run social && npm run cron",
|
||||
@@ -47,6 +51,7 @@
|
||||
"jsonpath-plus": "^10.4.0",
|
||||
"lodash": "^4.17.21",
|
||||
"masto": "^8.0.0",
|
||||
"mustache": "^4.2.0",
|
||||
"nxapi": "1.6.1-next.254",
|
||||
"p-filter": "^4.1.0",
|
||||
"p-limit": "^7.3.0",
|
||||
@@ -63,6 +68,7 @@
|
||||
"vue-router": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-plugin": "^1.1.2",
|
||||
"@dword-design/eslint-plugin-import-alias": "^8.1.3",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@intlify/unplugin-vue-i18n": "^11.2.5",
|
||||
@@ -76,7 +82,8 @@
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^4.1.11",
|
||||
"vue-eslint-parser": "^10.4.0"
|
||||
"vue-eslint-parser": "^10.4.0",
|
||||
"wrangler": "^4.127.1"
|
||||
},
|
||||
"overrides": {
|
||||
"sharp": "$sharp",
|
||||
|
||||
@@ -8,6 +8,9 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
test: {
|
||||
include: ['app/**/*.test.mjs', 'src/**/*.test.{js,mjs}'],
|
||||
include: [
|
||||
'app/**/*.test.mjs',
|
||||
'src/**/*.test.{js,mjs}',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
87
workers/assets-directory-browser/README.md
Normal file
87
workers/assets-directory-browser/README.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Assets directory browser
|
||||
|
||||
This Worker renders read-only directory listings for the public R2 bucket without
|
||||
running on ordinary object requests.
|
||||
|
||||
## Request routing
|
||||
|
||||
The browser-visible directory URLs end in `/`, for example:
|
||||
|
||||
- `https://assets.splatoon3.ink/`
|
||||
- `https://assets.splatoon3.ink/data/`
|
||||
- `https://assets.splatoon3.ink/splatnet/`
|
||||
|
||||
A Cloudflare URL Rewrite Rule rewrites only those requests to an internal prefix.
|
||||
The rewrite is not visible to the client.
|
||||
|
||||
Filter expression:
|
||||
|
||||
```text
|
||||
(http.host eq "assets.splatoon3.ink" and ends_with(http.request.uri.path, "/"))
|
||||
```
|
||||
|
||||
Dynamic path rewrite:
|
||||
|
||||
```text
|
||||
concat("/__directory", http.request.uri.path)
|
||||
```
|
||||
|
||||
The Wrangler configuration routes only `assets.splatoon3.ink/__directory/*` to
|
||||
the Worker. Every JSON file, calendar, image, and screenshot object continues to
|
||||
go directly to the R2 custom domain.
|
||||
|
||||
## JSON listings
|
||||
|
||||
Each directory has a machine-readable representation at the same public URL:
|
||||
|
||||
```text
|
||||
https://assets.splatoon3.ink/splatnet/images/?format=json
|
||||
```
|
||||
|
||||
The response contains immediate child `directories`, immediate child `files`,
|
||||
and an absolute `next` URL when another page is available. Listings are not
|
||||
recursive. Clients can walk the bucket by following directory URLs and must
|
||||
follow `next` verbatim rather than interpreting its opaque R2 cursor.
|
||||
|
||||
File records include their canonical URL, byte size, ETag, and R2 upload time.
|
||||
The HTML listing displays that same upload time in its **Last modified** column.
|
||||
JSON listing and error responses allow anonymous cross-origin reads with
|
||||
`Access-Control-Allow-Origin: *`.
|
||||
|
||||
## Local preview
|
||||
|
||||
Start the Worker with its explicit local-development flag:
|
||||
|
||||
```sh
|
||||
npm run assets-browser:dev
|
||||
```
|
||||
|
||||
Then open `http://localhost:8787/`. Local mode accepts the natural directory
|
||||
paths and serves linked objects from Wrangler's simulated R2 bucket. It never
|
||||
connects to the production bucket.
|
||||
|
||||
## Validate without deploying
|
||||
|
||||
```sh
|
||||
npm run assets-browser:test
|
||||
npm run assets-browser:deploy:dry-run
|
||||
```
|
||||
|
||||
`npm run assets-browser:deploy` performs the production deployment. Production
|
||||
deployments are handled by Cloudflare Workers Builds, configured from the
|
||||
repository root with `main` as the production branch,
|
||||
`npm run assets-browser:test` as the build command, and
|
||||
`npm run assets-browser:deploy` as the deploy command.
|
||||
|
||||
## Production rollout
|
||||
|
||||
Do not create the URL Rewrite Rule until the Worker route has been deployed and
|
||||
verified directly at an internal URL such as:
|
||||
|
||||
```text
|
||||
https://assets.splatoon3.ink/__directory/data/
|
||||
```
|
||||
|
||||
After that direct check succeeds, create the URL Rewrite Rule and verify the
|
||||
browser-visible `/`, `/data/`, and `/splatnet/` directory URLs. To roll back,
|
||||
disable the URL Rewrite Rule first, then remove the Worker route.
|
||||
361
workers/assets-directory-browser/src/index.js
Normal file
361
workers/assets-directory-browser/src/index.js
Normal file
@@ -0,0 +1,361 @@
|
||||
import Mustache from 'mustache';
|
||||
import template from './template.html';
|
||||
|
||||
const internalPrefix = '/__directory';
|
||||
const pageSize = 1000;
|
||||
|
||||
Mustache.parse(template);
|
||||
|
||||
/** @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 {string} key
|
||||
* @param {string} prefix
|
||||
*/
|
||||
function displayName(key, prefix) {
|
||||
return key.slice(prefix.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} Breadcrumb
|
||||
* @property {boolean} [current] Whether this is the current directory.
|
||||
* @property {string} [href] Link destination.
|
||||
* @property {boolean} [link] Whether this breadcrumb is a link.
|
||||
* @property {string} [name] Visible directory name.
|
||||
* @property {boolean} [separator] Whether this is a path separator.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} prefix
|
||||
* @returns {Breadcrumb[]} Breadcrumb presentation model.
|
||||
*/
|
||||
function breadcrumbs(prefix) {
|
||||
let parts = prefix.split('/').filter(Boolean);
|
||||
if (!parts.length) {
|
||||
return [{ current: true, name: 'assets' }];
|
||||
}
|
||||
|
||||
/** @type {Breadcrumb[]} */
|
||||
let result = [{ href: '/', link: true, name: 'assets' }];
|
||||
for (let index = 0; index < parts.length; index++) {
|
||||
result.push({ separator: true });
|
||||
if (index === parts.length - 1) {
|
||||
result.push({ current: true, name: parts[index] });
|
||||
} else {
|
||||
let path = `${parts.slice(0, index + 1).join('/')}/`;
|
||||
result.push({ href: publicPath(path), link: true, name: parts[index] });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @param {number} bytes */
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
let units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let unit = 'B';
|
||||
for (let candidate of units) {
|
||||
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 {string} origin
|
||||
* @param {string} prefix
|
||||
* @param {R2Objects} listing
|
||||
* @param {string | undefined} cursor
|
||||
*/
|
||||
function directoryPage(origin, prefix, listing, cursor) {
|
||||
let pathname = publicPath(prefix);
|
||||
|
||||
return {
|
||||
directories: listing.delimitedPrefixes.map(directoryPrefix => ({
|
||||
name: displayName(directoryPrefix, prefix),
|
||||
pathname: publicPath(directoryPrefix),
|
||||
prefix: directoryPrefix,
|
||||
url: new URL(publicPath(directoryPrefix), origin).href,
|
||||
})),
|
||||
files: listing.objects.map(object => ({
|
||||
etag: object.etag,
|
||||
key: object.key,
|
||||
name: displayName(object.key, prefix),
|
||||
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: cursor,
|
||||
url: new URL(pathname, origin).href,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
* @param {R2Bucket} bucket
|
||||
* @param {string} key
|
||||
*/
|
||||
async function localObjectResponse(request, bucket, key) {
|
||||
let object = await bucket.get(key);
|
||||
if (!object) {
|
||||
return errorResponse(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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof directoryPage>} page
|
||||
* @param {string} styleNonce
|
||||
*/
|
||||
function renderListing(page, styleNonce) {
|
||||
let rows = [
|
||||
...(page.prefix ? [{
|
||||
href: parentPath(page.prefix),
|
||||
isParent: true,
|
||||
name: 'Parent directory',
|
||||
}] : []),
|
||||
...page.directories.map(directory => ({
|
||||
href: directory.pathname,
|
||||
isFolder: true,
|
||||
name: directory.name,
|
||||
})),
|
||||
...page.files.map(file => ({
|
||||
date: formatDate(file.uploaded),
|
||||
datetime: file.uploaded.toISOString(),
|
||||
href: file.pathname,
|
||||
isFile: true,
|
||||
name: file.name,
|
||||
size: formatSize(file.size),
|
||||
})),
|
||||
];
|
||||
let folderCount = page.directories.length;
|
||||
let fileCount = page.files.length;
|
||||
|
||||
return Mustache.render(template, {
|
||||
breadcrumbs: breadcrumbs(page.prefix),
|
||||
canonicalUrl: page.url,
|
||||
hasRows: rows.length > 0,
|
||||
jsonCursor: page.requestedCursor ? encodeURIComponent(page.requestedCursor) : false,
|
||||
jsonPath: page.pathname,
|
||||
next: page.nextCursor ? {
|
||||
href: `${page.pathname}?cursor=${encodeURIComponent(page.nextCursor)}`,
|
||||
} : false,
|
||||
rows,
|
||||
styleNonce,
|
||||
summary: `${folderCount} ${folderCount === 1 ? 'folder' : 'folders'} · ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`,
|
||||
title: `Asset browser — ${page.pathname}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 {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 {number} status
|
||||
* @param {string} message
|
||||
* @param {HeadersInit} headers
|
||||
*/
|
||||
function errorResponse(status, message, headers = {}) {
|
||||
return new Response(message, {
|
||||
status,
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
* @param {number} status
|
||||
* @param {string} code
|
||||
* @param {string} message
|
||||
* @param {HeadersInit} headers
|
||||
*/
|
||||
function listingErrorResponse(request, status, code, message, headers = {}) {
|
||||
if (new URL(request.url).searchParams.get('format') !== 'json') {
|
||||
return errorResponse(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);
|
||||
}
|
||||
|
||||
/** @type {ExportedHandler<Env>} */
|
||||
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 listingErrorResponse(request, 405, 'method_not_allowed', 'Method not allowed', { Allow: 'GET, HEAD' });
|
||||
}
|
||||
|
||||
let encodedPath;
|
||||
if (url.pathname.startsWith(`${internalPrefix}/`)) {
|
||||
encodedPath = url.pathname.slice(internalPrefix.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 listingErrorResponse(request, 400, 'invalid_path', 'Invalid path');
|
||||
}
|
||||
return localObjectResponse(request, env.ASSETS, key);
|
||||
} else {
|
||||
return listingErrorResponse(request, 404, 'not_found', 'Not found');
|
||||
}
|
||||
|
||||
let prefix;
|
||||
try {
|
||||
prefix = decodeURIComponent(encodedPath);
|
||||
} catch {
|
||||
return listingErrorResponse(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;
|
||||
/** @type {R2Objects} */
|
||||
let listing;
|
||||
try {
|
||||
listing = await env.ASSETS.list({
|
||||
cursor,
|
||||
delimiter: '/',
|
||||
limit: pageSize,
|
||||
prefix,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
message: 'R2 directory listing failed',
|
||||
prefix,
|
||||
}));
|
||||
return listingErrorResponse(request, 500, 'listing_unavailable', 'Directory listing unavailable');
|
||||
}
|
||||
if (prefix && !listing.objects.length && !listing.delimitedPrefixes.length) {
|
||||
return listingErrorResponse(request, 404, 'directory_not_found', 'Directory not found');
|
||||
}
|
||||
|
||||
let page = directoryPage(url.origin, prefix, listing, cursor);
|
||||
if (url.searchParams.get('format') === 'json') {
|
||||
return jsonListingResponse(request, page);
|
||||
}
|
||||
|
||||
let styleNonce = crypto.randomUUID().replaceAll('-', '');
|
||||
let html = renderListing(page, styleNonce);
|
||||
|
||||
return new Response(request.method === 'HEAD' ? null : html, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Security-Policy': `default-src 'none'; style-src 'nonce-${styleNonce}'; base-uri 'none'; frame-ancestors 'none'`,
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Robots-Tag': 'noindex',
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
286
workers/assets-directory-browser/src/index.test.mjs
Normal file
286
workers/assets-directory-browser/src/index.test.mjs
Normal file
@@ -0,0 +1,286 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { env } from 'cloudflare:workers';
|
||||
import worker from './index.js';
|
||||
|
||||
class FakeBucket
|
||||
{
|
||||
calls = [];
|
||||
|
||||
constructor(result = {}) {
|
||||
this.result = {
|
||||
cursor: 'next page',
|
||||
delimitedPrefixes: ['data/locale/'],
|
||||
objects: [{
|
||||
key: 'data/schedules.json',
|
||||
size: 54861,
|
||||
uploaded: new Date('2026-08-30T17:30:26Z'),
|
||||
}],
|
||||
truncated: true,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
async list(options) {
|
||||
this.calls.push(options);
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
|
||||
describe('assets directory browser', () => {
|
||||
it('uses natural directory paths only when local development is enabled', async () => {
|
||||
await env.ASSETS.put('local/example.json', '{}');
|
||||
let localEnv = {
|
||||
ASSETS: env.ASSETS,
|
||||
LOCAL_DEVELOPMENT: 'true',
|
||||
};
|
||||
|
||||
let localResponse = await worker.fetch(
|
||||
new Request('http://localhost:8787/local/'),
|
||||
localEnv,
|
||||
);
|
||||
let productionResponse = await worker.fetch(
|
||||
new Request('https://assets.splatoon3.ink/local/'),
|
||||
env,
|
||||
);
|
||||
|
||||
expect(localResponse.status).toBe(200);
|
||||
expect(await localResponse.text()).toContain('href="/local/example.json"');
|
||||
expect(productionResponse.status).toBe(404);
|
||||
});
|
||||
|
||||
it('serves linked objects from local R2 only in local development', async () => {
|
||||
await env.ASSETS.put('local/example.json', '{"local":true}', {
|
||||
httpMetadata: { contentType: 'application/json' },
|
||||
});
|
||||
let localEnv = {
|
||||
ASSETS: env.ASSETS,
|
||||
LOCAL_DEVELOPMENT: 'true',
|
||||
};
|
||||
|
||||
let response = await worker.fetch(
|
||||
new Request('http://localhost:8787/local/example.json'),
|
||||
localEnv,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe('application/json');
|
||||
expect(await response.text()).toBe('{"local":true}');
|
||||
});
|
||||
|
||||
it('lists objects from the configured R2 binding using canonical public links', async () => {
|
||||
await env.ASSETS.put('data/schedules.json', 'schedule data');
|
||||
await env.ASSETS.put('data/locale/en-US.json', 'locale data');
|
||||
|
||||
let request = new Request(
|
||||
'https://assets.splatoon3.ink/__directory/data/',
|
||||
);
|
||||
|
||||
let response = await worker.fetch(request, env);
|
||||
let html = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8');
|
||||
expect(html).not.toContain('<h1>Asset browser</h1>');
|
||||
expect(html).not.toContain('Public Splatoon 3 data and media');
|
||||
expect(html).toContain('<span aria-current="page">data</span>');
|
||||
expect(html).toContain('href="/"');
|
||||
expect(html).toContain('href="/data/locale/"');
|
||||
expect(html).toContain('href="/data/schedules.json"');
|
||||
expect(html).not.toContain('__directory');
|
||||
});
|
||||
|
||||
it('returns a public JSON representation of a directory page', async () => {
|
||||
let bucket = new FakeBucket({
|
||||
cursor: 'next page',
|
||||
delimitedPrefixes: ['splatnet/images/badges/'],
|
||||
objects: [{
|
||||
etag: 'opaque-etag',
|
||||
key: 'splatnet/images/example.png',
|
||||
size: 48123,
|
||||
uploaded: new Date('2026-08-30T01:02:03Z'),
|
||||
}],
|
||||
truncated: true,
|
||||
});
|
||||
let response = await worker.fetch(new Request(
|
||||
'https://assets.splatoon3.ink/__directory/splatnet/images/?format=json&cursor=current%20page',
|
||||
), { ASSETS: bucket });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe('*');
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
expect(response.headers.get('content-type')).toBe('application/json; charset=utf-8');
|
||||
expect(await response.json()).toEqual({
|
||||
schemaVersion: 1,
|
||||
directory: {
|
||||
prefix: 'splatnet/images/',
|
||||
url: 'https://assets.splatoon3.ink/splatnet/images/',
|
||||
},
|
||||
directories: [{
|
||||
name: 'badges/',
|
||||
prefix: 'splatnet/images/badges/',
|
||||
url: 'https://assets.splatoon3.ink/splatnet/images/badges/',
|
||||
}],
|
||||
files: [{
|
||||
name: 'example.png',
|
||||
key: 'splatnet/images/example.png',
|
||||
url: 'https://assets.splatoon3.ink/splatnet/images/example.png',
|
||||
size: 48123,
|
||||
uploaded: '2026-08-30T01:02:03.000Z',
|
||||
etag: 'opaque-etag',
|
||||
}],
|
||||
next: 'https://assets.splatoon3.ink/splatnet/images/?format=json&cursor=next%20page',
|
||||
});
|
||||
expect(bucket.calls).toEqual([{
|
||||
cursor: 'current page',
|
||||
delimiter: '/',
|
||||
limit: 1000,
|
||||
prefix: 'splatnet/images/',
|
||||
}]);
|
||||
});
|
||||
|
||||
it('renders responsive light and dark themes under a nonce-based CSP', async () => {
|
||||
let response = await worker.fetch(
|
||||
new Request('https://assets.splatoon3.ink/__directory/'),
|
||||
env,
|
||||
);
|
||||
let html = await response.text();
|
||||
let policy = response.headers.get('content-security-policy');
|
||||
let nonce = policy?.match(/style-src 'nonce-([^']+)'/)?.[1];
|
||||
|
||||
expect(nonce).toBeTruthy();
|
||||
expect(html).toContain(`<style nonce="${nonce}">`);
|
||||
expect(html).toContain('@media (prefers-color-scheme: dark)');
|
||||
expect(html).toContain('@media (max-width: 640px)');
|
||||
expect(html).toContain('<meta name="color-scheme" content="light dark">');
|
||||
});
|
||||
|
||||
it('passes an opaque pagination cursor to R2', async () => {
|
||||
let bucket = new FakeBucket;
|
||||
let request = new Request(
|
||||
'https://assets.splatoon3.ink/__directory/data/?cursor=current%20page',
|
||||
);
|
||||
|
||||
let response = await worker.fetch(request, { ASSETS: bucket });
|
||||
let html = await response.text();
|
||||
|
||||
expect(bucket.calls).toEqual([{
|
||||
cursor: 'current page',
|
||||
delimiter: '/',
|
||||
limit: 1000,
|
||||
prefix: 'data/',
|
||||
}]);
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('href="/data/?cursor=next%20page"');
|
||||
expect(html).toContain('<link rel="alternate" type="application/json" href="/data/?format=json&cursor=current%20page">');
|
||||
expect(html).toContain('href="/data/?format=json&cursor=current%20page">JSON</a>');
|
||||
});
|
||||
|
||||
it('renders a safe root listing with object metadata', async () => {
|
||||
let bucket = new FakeBucket({
|
||||
cursor: undefined,
|
||||
delimitedPrefixes: ['data/'],
|
||||
objects: [{
|
||||
key: 'odd<&".json',
|
||||
size: 1536,
|
||||
uploaded: new Date('2026-08-30T17:30:26Z'),
|
||||
}],
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
let response = await worker.fetch(
|
||||
new Request('https://assets.splatoon3.ink/__directory/'),
|
||||
{ ASSETS: bucket },
|
||||
);
|
||||
let html = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('<link rel="canonical" href="https://assets.splatoon3.ink/">');
|
||||
expect(html).not.toContain('>../</a>');
|
||||
expect(html).toContain('href="/data/"');
|
||||
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');
|
||||
});
|
||||
|
||||
it('rejects unsupported methods without listing the bucket', async () => {
|
||||
let bucket = new FakeBucket;
|
||||
let response = await worker.fetch(new Request(
|
||||
'https://assets.splatoon3.ink/__directory/data/',
|
||||
{ method: 'POST' },
|
||||
), { ASSETS: bucket });
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get('allow')).toBe('GET, HEAD');
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
expect(bucket.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns a non-cacheable 404 for an unknown prefix', async () => {
|
||||
let response = await worker.fetch(new Request(
|
||||
'https://assets.splatoon3.ink/__directory/missing/',
|
||||
), env);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
expect(await response.text()).toBe('Directory not found');
|
||||
});
|
||||
|
||||
it('returns machine-readable errors for JSON listing requests', async () => {
|
||||
let response = await worker.fetch(new Request(
|
||||
'https://assets.splatoon3.ink/__directory/missing/?format=json',
|
||||
), env);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe('*');
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
expect(response.headers.get('content-type')).toBe('application/json; charset=utf-8');
|
||||
expect(await response.json()).toEqual({
|
||||
schemaVersion: 1,
|
||||
error: {
|
||||
code: 'directory_not_found',
|
||||
message: 'Directory not found',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsupported listing formats without reading R2', async () => {
|
||||
let bucket = new FakeBucket;
|
||||
let response = await worker.fetch(new Request(
|
||||
'https://assets.splatoon3.ink/__directory/data/?format=xml',
|
||||
), { ASSETS: bucket });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe('*');
|
||||
expect(await response.json()).toEqual({
|
||||
schemaVersion: 1,
|
||||
error: {
|
||||
code: 'unsupported_format',
|
||||
message: 'Unsupported format',
|
||||
},
|
||||
});
|
||||
expect(bucket.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns a controlled error when R2 listing 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.splatoon3.ink/__directory/data/',
|
||||
), { ASSETS: bucket });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
expect(await response.text()).toBe('Directory listing unavailable');
|
||||
expect(errorLog).toHaveBeenCalledWith(JSON.stringify({
|
||||
error: 'R2 unavailable',
|
||||
message: 'R2 directory listing failed',
|
||||
prefix: 'data/',
|
||||
}));
|
||||
errorLog.mockRestore();
|
||||
});
|
||||
});
|
||||
338
workers/assets-directory-browser/src/template.html
Normal file
338
workers/assets-directory-browser/src/template.html
Normal file
@@ -0,0 +1,338 @@
|
||||
<!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="{{{canonicalUrl}}}">
|
||||
<link rel="alternate" type="application/json" href="{{{jsonPath}}}?format=json{{#jsonCursor}}&cursor={{jsonCursor}}{{/jsonCursor}}">
|
||||
<title>{{title}}</title>
|
||||
<style nonce="{{styleNonce}}">
|
||||
: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">{{#breadcrumbs}}{{#separator}}<span class="breadcrumb-separator" aria-hidden="true">/</span>{{/separator}}{{#link}}<a href="{{{href}}}">{{name}}</a>{{/link}}{{#current}}<span aria-current="page">{{name}}</span>{{/current}}{{/breadcrumbs}}</nav>
|
||||
<div class="browser-meta">
|
||||
<span class="summary">{{summary}}</span>
|
||||
<a class="json-link" href="{{{jsonPath}}}?format=json{{#jsonCursor}}&cursor={{jsonCursor}}{{/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}}
|
||||
<li>
|
||||
<a class="listing-row{{#isParent}} parent-row{{/isParent}}" href="{{{href}}}">
|
||||
<span class="listing-cell entry"><span class="visually-hidden">Name: </span>{{#isParent}}<span class="entry-icon entry-icon--parent" aria-hidden="true">↑</span>{{/isParent}}{{#isFolder}}<span class="entry-icon entry-icon--folder" aria-hidden="true"></span>{{/isFolder}}{{#isFile}}<span class="entry-icon entry-icon--file" aria-hidden="true"></span>{{/isFile}}<span class="entry-name">{{name}}</span></span>
|
||||
<span class="listing-cell">{{#isFile}}<span class="visually-hidden">Last modified: </span><time datetime="{{datetime}}">{{date}}</time>{{/isFile}}</span>
|
||||
<span class="listing-cell">{{#isFolder}}<span class="visually-hidden">Type: </span><span class="type-label">Folder</span>{{/isFolder}}{{#isFile}}<span class="visually-hidden">Size: </span>{{size}}{{/isFile}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{{/rows}}
|
||||
{{^hasRows}}<li class="empty-state">This directory is empty.</li>{{/hasRows}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="browser-footer">{{#next}}<a class="next-page" rel="next" href="{{{href}}}">Next page <span aria-hidden="true">→</span></a>{{/next}}</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
15
workers/assets-directory-browser/vitest.config.mjs
Normal file
15
workers/assets-directory-browser/vitest.config.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cloudflareTest } from '@cloudflare/vitest-plugin';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
cloudflareTest({
|
||||
wrangler: {
|
||||
configPath: './workers/assets-directory-browser/wrangler.jsonc',
|
||||
},
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
include: ['workers/assets-directory-browser/src/**/*.test.mjs'],
|
||||
},
|
||||
});
|
||||
31
workers/assets-directory-browser/wrangler.jsonc
Normal file
31
workers/assets-directory-browser/wrangler.jsonc
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "../../node_modules/wrangler/config-schema.json",
|
||||
"name": "splatoon3-assets-directory-browser",
|
||||
"main": "src/index.js",
|
||||
"compatibility_date": "2026-08-30",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"workers_dev": false,
|
||||
"preview_urls": false,
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "assets.splatoon3.ink/__directory/*",
|
||||
"zone_name": "splatoon3.ink"
|
||||
}
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "ASSETS",
|
||||
"bucket_name": "splatoon3-ink-assets"
|
||||
}
|
||||
],
|
||||
"observability": {
|
||||
"enabled": true,
|
||||
"logs": {
|
||||
"head_sampling_rate": 1
|
||||
},
|
||||
"traces": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 0.01
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user