Use Cloudflare workers to host + sync with Spelling Bee.

This commit is contained in:
Brent Schroeter 2022-04-04 09:47:52 -07:00
parent 57c05a265a
commit a9d9c1a3af
22 changed files with 9505 additions and 18413 deletions

View file

@ -1 +0,0 @@
module.exports = {};

5
.gitignore vendored
View file

@ -1,5 +1,7 @@
dist
public
.cache
.parcel-cache
# Logs
logs
@ -67,3 +69,6 @@ node_modules/
# dotenv environment variables file
.env
# Workers
transpiled

6
.parcelrc Normal file
View file

@ -0,0 +1,6 @@
{
"extends": "@parcel/config-default",
"transformers": {
"*.{ts,tsx}": ["@parcel/transformer-typescript-tsc"]
}
}

20410
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,10 +2,9 @@
"name": "built-to-spell",
"version": "0.0.1",
"description": "NYT Spelling Bee simulator",
"main": "index.js",
"scripts": {
"lint": "eslint src",
"build": "rm -r .cache ; parcel build -d public src/index.html",
"build": "parcel build --no-cache --dist-dir=public src/index.html",
"start": "rm -r .cache ; parcel -d public src/index.html"
},
"repository": {
@ -19,20 +18,27 @@
},
"homepage": "https://gitlab.com/brentschroeter/built-to-spell#readme",
"dependencies": {
"react": "^17.0.1",
"react-dom": "^17.0.1"
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"@babel/core": "^7.12.10",
"@types/node": "^14.14.20",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@typescript-eslint/eslint-plugin": "^4.11.1",
"eslint": "^7.17.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-react": "^7.22.0",
"parcel-bundler": "^1.12.4",
"typescript": "^4.1.3"
"@babel/core": "^7.17.8",
"@parcel/packager-raw-url": "^2.4.0",
"@parcel/transformer-typescript-tsc": "^2.4.0",
"@parcel/transformer-webmanifest": "^2.4.0",
"@types/node": "^17.0.23",
"@types/react": "^17.0.43",
"@types/react-dom": "^17.0.14",
"@typescript-eslint/eslint-plugin": "^5.16.0",
"@typescript-eslint/parser": "^5.16.0",
"eslint": "^8.12.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint_d": "^11.1.1",
"parcel": "^2.4.0",
"typescript": "^4.6.3"
}
}

View file

@ -13,6 +13,6 @@
</head>
<body class="bg-gray-50">
<div id="app-root" class="w-screen h-screen"></div>
<script type="text/javascript" src="./index.tsx"></script>
<script type="module" src="./index.tsx"></script>
</body>
</html>

View file

@ -2,12 +2,15 @@ import React from 'react';
import { render } from 'react-dom';
import App from './app';
import { NytProvider } from './nyt';
import { StoreProvider } from './store';
render(
(
<StoreProvider>
<NytProvider>
<App />
</NytProvider>
</StoreProvider>
),
document.getElementById('app-root'),

View file

@ -1,6 +1,6 @@
{
"icons": [
{"src": "/icon-192.png", "type": "image/png", "sizes": "192x192"},
{"src": "/icon-512.png", "type": "image/png", "sizes": "512x512"}
{"src": "./icon-192.png", "type": "image/png", "sizes": "192x192"},
{"src": "./icon-512.png", "type": "image/png", "sizes": "512x512"}
]
}

37
src/nyt.tsx Normal file
View file

@ -0,0 +1,37 @@
import React, {
createContext,
FC,
useEffect,
useState,
} from 'react';
interface GameData {
today: {
validLetters: string[];
};
}
export interface NytData {
letters: string[];
}
export const Context = createContext<NytData>({ letters: [] });
export const NytProvider: FC = ({ children }) => {
const [nytData, setNytData] = useState({ letters: [] } as NytData);
useEffect(() => {
fetch('/api/game-data/latest')
.then((res) => res.json() as Promise<GameData>)
.then((body) => {
setNytData({ letters: body.today.validLetters.map((x) => x.toLocaleUpperCase('en')) });
})
.catch(console.error);
});
return (
<Context.Provider value={nytData}>
{children}
</Context.Provider>
);
};

View file

@ -5,6 +5,7 @@ import React, {
useEffect,
} from 'react';
import { Context as NytContext } from './nyt';
import { StoreContext } from './store';
const range7 = [0, 1, 2, 3, 4, 5, 6];
@ -12,8 +13,12 @@ const range7 = [0, 1, 2, 3, 4, 5, 6];
const isValidLetter = (ch: string): boolean => ch.length === 1 && ch >= 'A' && ch <= 'Z';
const SetupView: FunctionComponent = () => {
const { dispatch, state } = useContext(StoreContext);
const { letters } = state;
const {
dispatch,
state: { letters },
} = useContext(StoreContext);
const { letters: nytLetters } = useContext(NytContext);
useEffect(() => {
const firstInput = document.getElementById('setup-view-letter-selector-0');
@ -82,12 +87,23 @@ const SetupView: FunctionComponent = () => {
/>
))}
</div>
<div className="flex justify-center">
<button
className={`
my-6 px-4 py-2 rounded font-bold text-sm
w-full my-6 px-4 py-2 rounded font-bold text-sm
bg-yellow-400 hover:bg-yellow-500 transition-all
${letters.length < 7 ? 'opacity-0 cursor-default' : ''}
${nytLetters.length === 7 ? 'bg-yellow-400 cursor-pointer' : 'bg-gray-400 cursor-default'}
`}
disabled={nytLetters.length !== 7}
onClick={() => dispatch({ type: 'SET_LETTERS', payload: nytLetters })}
type="button"
>
{nytLetters.length === 7 ? 'Populate from NYT' : 'Loading from NYT...'}
</button>
<button
className={`
w-full my-6 px-4 py-2 rounded font-bold text-sm
bg-yellow-400 hover:bg-yellow-500 transition-all
${letters.length < 7 ? 'opacity-0 cursor-default' : 'cursor-pointer'}
`}
disabled={letters.length !== 7}
onClick={() => dispatch({ type: 'SWITCH_VIEW', payload: 'GAME' })}
@ -96,7 +112,6 @@ const SetupView: FunctionComponent = () => {
Play Built to Spell
</button>
</div>
</div>
);
};

View file

@ -33,7 +33,7 @@
"isolatedModules": true
},
"include": [
"src/**/*",
"src/",
".eslintrc.js",
],
"exclude": [

0
workers-site/.cargo-ok Normal file
View file

170
workers-site/.eslintrc.js Normal file
View file

@ -0,0 +1,170 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: { jsx: true },
ecmaVersion: 12,
project: ['./tsconfig.json'],
sourceType: 'module',
},
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
'plugin:jsx-a11y/recommended',
'plugin:react/recommended',
'airbnb',
],
plugins: [
'@typescript-eslint',
'import',
'jsx-a11y',
'react',
'react-hooks',
],
rules: {
'@typescript-eslint/brace-style': ['error'],
'@typescript-eslint/comma-dangle': ['error', 'always-multiline'],
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
'@typescript-eslint/keyword-spacing': ['error'],
'@typescript-eslint/member-delimiter-style': ['error'],
'@typescript-eslint/method-signature-style': ['warn', 'method'],
'@typescript-eslint/naming-convention': [
'warn',
{
selector: 'default',
format: ['camelCase', 'PascalCase'],
leadingUnderscore: 'forbid',
trailingUnderscore: 'forbid',
filter: {
// skip names requiring quotes, e.g. HTTP headers
regex: '[- ]',
match: false,
},
},
{
selector: ['enumMember'],
format: ['UPPER_CASE'],
},
{
selector: ['property', 'parameter', 'parameterProperty'],
format: ['camelCase', 'PascalCase'],
leadingUnderscore: 'allow',
trailingUnderscore: 'forbid',
filter: {
// skip names requiring quotes, e.g. HTTP headers
regex: '[- ]',
match: false,
},
},
{
selector: 'typeLike',
format: ['PascalCase'],
},
],
'@typescript-eslint/no-duplicate-imports': ['error'],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': ['error'],
'@typescript-eslint/no-misused-promises': ['error'],
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-shadow': ['error'],
'@typescript-eslint/no-unnecessary-condition': ['error', { allowConstantLoopConditions: true }],
'@typescript-eslint/no-unnecessary-boolean-literal-compare': ['error'],
'@typescript-eslint/no-unused-expressions': ['error'],
'@typescript-eslint/no-unused-vars': ['error'],
'@typescript-eslint/no-use-before-define': ['error'],
'@typescript-eslint/prefer-nullish-coalescing': ['warn'],
'@typescript-eslint/quotes': ['error', 'single', { avoidEscape: true }],
'@typescript-eslint/semi': ['error'],
'@typescript-eslint/space-before-function-paren': ['error', {
anonymous: 'never',
named: 'never',
asyncArrow: 'always',
}],
'@typescript-eslint/switch-exhaustiveness-check': ['error'],
'@typescript-eslint/type-annotation-spacing': ['warn', {
before: false,
after: true,
overrides: {
arrow: { before: true },
},
}],
'brace-style': 'off',
camelcase: 'off',
'class-methods-use-this': 'off',
'comma-dangle': 'off',
'keyword-spacing': 'off',
'no-duplicate-imports': 'off',
'no-shadow': 'off',
'no-unused-expressions': 'off',
'no-unused-vars': 'off',
'no-use-before-define': 'off',
quotes: 'off',
semi: 'off',
'space-before-function-paren': 'off',
'implicit-arrow-linebreak': 'off',
'import/extensions': [
'error',
'ignorePackages',
{
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
},
],
indent: ['error', 2, {
CallExpression: { arguments: 'first' },
FunctionDeclaration: { parameters: 'first' },
FunctionExpression: { parameters: 'first' },
SwitchCase: 1,
VariableDeclarator: 'first',
offsetTernaryExpressions: true,
}],
'jsx-a11y/label-has-associated-control': ['warn', {
assert: 'either',
}],
'max-classes-per-file': 'off',
'max-len': ['warn', { code: 100, tabWidth: 2 }],
'no-console': 'off',
'no-underscore-dangle': 'off',
'prefer-arrow-callback': ['error'],
'react/function-component-definition': ['error', {
namedComponents: 'arrow-function',
unnamedComponents: 'arrow-function',
}],
'react/jsx-curly-newline': ['error', { multiline: 'forbid', singleline: 'forbid' }],
'react/jsx-filename-extension': ['error', { extensions: ['.jsx', '.tsx'] }],
'react/jsx-first-prop-new-line': ['error', 'multiline'],
'react/jsx-fragments': 'off',
'react/jsx-indent': ['error', 2, { checkAttributes: true, indentLogicalExpressions: true }],
'react/jsx-indent-props': ['error', 2],
'react/jsx-key': ['error', { checkFragmentShorthand: true }],
'react/jsx-max-props-per-line': ['error', { maximum: 3, when: 'always' }],
'react/jsx-no-target-blank': ['error'],
'react/jsx-wrap-multilines': ['error', {
declaration: 'parens-new-line',
assignment: 'parens-new-line',
return: 'parens-new-line',
arrow: 'parens-new-line',
condition: 'parens-new-line',
logical: 'parens-new-line',
prop: 'parens-new-line',
}],
'react/prop-types': 'off',
'react/no-deprecated': ['error'],
'react/no-multi-comp': ['error'],
'react/no-unused-prop-types': ['error'],
'react/prefer-stateless-function': ['error'],
'react/react-in-jsx-scope': 'off',
'react-hooks/rules-of-hooks': ['error'],
'react-hooks/exhaustive-deps': ['warn'],
},
settings: {
'import/resolver': {
node: { extensions: ['.js', '.jsx', '.ts', '.tsx'] },
},
},
};

2
workers-site/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules
worker

View file

@ -0,0 +1,7 @@
{
"transform": {
"^.+\\.(t|j)sx?$": "ts-jest"
},
"testRegex": "/test/.*\\.test\\.ts$",
"collectCoverageFrom": ["src/**/*.{ts,js}"]
}

5662
workers-site/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

36
workers-site/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "worker",
"version": "1.0.0",
"description": "",
"main": "dist/worker.js",
"author": "",
"license": "MIT",
"scripts": {
"build": "webpack",
"format": "prettier --write '*.{json,js}' 'src/**/*.{js,ts}' 'test/**/*.{js,ts}'",
"lint": "eslint --max-warnings=0 src && prettier --check '*.{json,js}' 'src/**/*.{js,ts}' 'test/**/*.{js,ts}'",
"test": "jest --config jestconfig.json --verbose"
},
"dependencies": {
"@cloudflare/kv-asset-handler": "~0.1.2"
},
"devDependencies": {
"@cloudflare/workers-types": "^3.0.0",
"@types/jest": "^26.0.23",
"@types/service-worker-mock": "^2.0.1",
"@typescript-eslint/eslint-plugin": "^4.16.1",
"@typescript-eslint/parser": "^4.16.1",
"eslint": "^7.21.0",
"eslint-config-prettier": "^8.1.0",
"eslint-config-typescript": "^3.0.0",
"eslint_d": "^11.1.1",
"jest": "^27.0.1",
"prettier": "^2.3.0",
"service-worker-mock": "^2.0.5",
"ts-jest": "^27.0.1",
"ts-loader": "^9.2.8",
"typescript": "^4.6.3",
"webpack": "^5.70.0",
"webpack-cli": "^4.9.2"
}
}

5
workers-site/src/bindings.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
export {};
declare global {
const NYT_GAME_DATA: KVNamespace;
}

102
workers-site/src/index.ts Normal file
View file

@ -0,0 +1,102 @@
import {
getAssetFromKV,
Options as GetAssetFromKVOptions,
} from '@cloudflare/kv-asset-handler';
/**
* The DEBUG flag will do two things that help during development:
* 1. we will skip caching on the edge, which makes it easier to
* debug.
* 2. we will return an error message on exception in your Response rather
* than the default 404.html page.
*/
const DEBUG = false;
addEventListener('fetch', (event) => {
try {
event.respondWith(handleFetch(event));
} catch (e) {
if (e instanceof Error && DEBUG) {
return event.respondWith(new Response(e.message || e.toString(), { status: 500 }));
}
event.respondWith(new Response('Internal Error', { status: 500 }));
}
});
addEventListener('scheduled', (event) => {
event.waitUntil(handleCron());
});
interface DailyGameData {
printDate: string;
centerLetter: string;
outerLetters: string[];
answers: string[];
}
interface GameData {
today: DailyGameData;
}
async function handleFetch(event: FetchEvent) {
const url = new URL(event.request.url);
const options: Partial<GetAssetFromKVOptions> = {};
try {
if (DEBUG) {
options.cacheControl = { bypassCache: true };
}
let response = new Response('', {});
if (url.pathname.toLocaleLowerCase('en') === '/api/game-data/latest') {
const gameData = await NYT_GAME_DATA.get('latest');
response = new Response(gameData || '', { status: gameData ? 200 : 500 });
response.headers.set('Content-Type', 'application/json');
} else {
const page = await getAssetFromKV(event, options);
response = new Response(page.body, page);
}
response.headers.set('X-XSS-Protection', '1; mode=block');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('Referrer-Policy', 'unsafe-url');
response.headers.set('Feature-Policy', 'none');
return response;
} catch (e) {
// if an error is thrown try to serve the asset at 404.html
if (e instanceof Error && DEBUG) {
return new Response(e.message || e.toString(), { status: 500 });
}
try {
let notFoundResponse = await getAssetFromKV(event, {
mapRequestToAsset: (req) => new Request(`${new URL(req.url).origin}/404.html`, req),
});
return new Response(notFoundResponse.body, { ...notFoundResponse, status: 404 });
} catch (e) { /* no-op */ }
return new Response('Not found.', { status: 404 });
}
}
async function handleCron() {
console.log('Starting cron trigger...');
const resp = await fetch('https://www.nytimes.com/puzzles/spelling-bee');
console.log('Fetched from NYT.');
let text = await resp.text();
const startStr = '<script type="text/javascript">window.gameData = {';
const startIdx = text.indexOf(startStr);
if (startIdx === -1) {
throw new Error('Unable to locate gameData.');
}
text = text.slice(startIdx + startStr.length - 1);
const endIdx = text.indexOf('</script>');
if (endIdx === -1) {
throw new Error('Unable to locate end of gameData.');
}
text = text.slice(0, endIdx);
console.log('Found gameData.');
const gameData = JSON.parse(text) as GameData;
console.log('Parsed gameData.');
await Promise.all([
NYT_GAME_DATA.put('latest', text),
NYT_GAME_DATA.put(gameData.today.printDate, text),
]);
console.log('Saved gameData.');
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"outDir": "./dist",
"module": "commonjs",
"target": "esnext",
"lib": ["esnext"],
"alwaysStrict": true,
"strict": true,
"preserveConstEnums": true,
"moduleResolution": "node",
"sourceMap": true,
"esModuleInterop": true,
"types": [
"@cloudflare/workers-types",
"@types/jest",
"@types/service-worker-mock"
]
},
"include": ["src"],
"exclude": ["node_modules", "dist", "test"]
}

View file

@ -0,0 +1,22 @@
const path = require('path');
module.exports = {
entry: path.join(__dirname, 'src/index.ts'),
output: {
filename: 'worker.js',
path: path.join(__dirname, 'dist'),
},
devtool: 'cheap-module-source-map',
mode: 'development',
resolve: {
extensions: ['.ts', '.tsx', '.js'],
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'ts-loader',
},
],
},
}

View file

@ -0,0 +1,26 @@
name = "built-to-spell"
type = "javascript"
route = 'bts.brentsch.com/*'
account_id = '7c81d50e4fc6ec51eecd802aacd6304b'
zone_id = '23121cb221cde0d0fbcb10798c01f8ba'
usage_model = ''
compatibility_flags = []
workers_dev = false
compatibility_date = "2022-03-27"
[triggers]
crons = ["0 * * * *"]
[build]
command = "npm --prefix=../ run build && npm run build"
[build.upload]
format = "service-worker"
[[kv_namespaces]]
binding = "NYT_GAME_DATA"
id = "ad58b382cc884b889f42ed94cf4dbb6b"
[site]
bucket = "../public"
entry-point = "./"