mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-06-16 01:29:09 +00:00
initial port to cfworkers i guess
This commit is contained in:
parent
ab9fe30b42
commit
9eeb946389
37 changed files with 367 additions and 1098 deletions
1
src/backend-worker/.gitignore
vendored
Normal file
1
src/backend-worker/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
dist
|
10
src/backend-worker/bindings.d.ts
vendored
Normal file
10
src/backend-worker/bindings.d.ts
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
export {};
|
||||
|
||||
declare global {
|
||||
const BOT_CLIENT_ID: string;
|
||||
const BOT_CLIENT_SECRET: string;
|
||||
const UI_PUBLIC_URI: string;
|
||||
const API_PUBLIC_URI: string;
|
||||
const ROOT_USERS: string;
|
||||
const KV_PREFIX: string;
|
||||
}
|
35
src/backend-worker/handlers/bot-join.ts
Normal file
35
src/backend-worker/handlers/bot-join.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { Bounce } from '../utils/bounce';
|
||||
|
||||
const validGuildID = /^[0-9]+$/;
|
||||
|
||||
type URLParams = {
|
||||
clientID: string;
|
||||
permissions: number;
|
||||
guildID?: string;
|
||||
};
|
||||
|
||||
const buildURL = (params: URLParams) => {
|
||||
let url = `https://discord.com/api/oauth2/authorize?client_id=${params.clientID}&scope=bot&permissions=${params.permissions}`;
|
||||
|
||||
if (params.guildID) {
|
||||
url += `&guild_id=${params.guildID}&disable_guild_select=true`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
export const BotJoin = (request: Request): Response => {
|
||||
let guildID = new URL(request.url).searchParams.get('guild') || '';
|
||||
|
||||
if (guildID && !validGuildID.test(guildID)) {
|
||||
guildID = '';
|
||||
}
|
||||
|
||||
return Bounce(
|
||||
buildURL({
|
||||
clientID: BOT_CLIENT_ID,
|
||||
permissions: 268435456,
|
||||
guildID,
|
||||
})
|
||||
);
|
||||
};
|
23
src/backend-worker/handlers/login-bounce.ts
Normal file
23
src/backend-worker/handlers/login-bounce.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Bounce } from '../utils/bounce';
|
||||
|
||||
type URLParams = {
|
||||
clientID: string;
|
||||
redirectURI: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
const buildURL = (params: URLParams) =>
|
||||
`https://discord.com/api/oauth2/authorize?client_id=${
|
||||
params.clientID
|
||||
}&response_type=code&scope=identify%20guilds&redirect_uri=${encodeURIComponent(
|
||||
params.redirectURI
|
||||
)}&state=${params.state}`;
|
||||
|
||||
export const LoginBounce = (request: Request): Response => {
|
||||
const state = uuidv4();
|
||||
const redirectURI = `${API_PUBLIC_URI}/login-callback`;
|
||||
const clientID = BOT_CLIENT_ID;
|
||||
|
||||
return Bounce(buildURL({ state, redirectURI, clientID }));
|
||||
};
|
3
src/backend-worker/handlers/login-callback.ts
Normal file
3
src/backend-worker/handlers/login-callback.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const LoginCallback = (request: Request): Response => {
|
||||
return new Response('login-callback!');
|
||||
};
|
14
src/backend-worker/index.ts
Normal file
14
src/backend-worker/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { BotJoin } from './handlers/bot-join';
|
||||
import { LoginBounce } from './handlers/login-bounce';
|
||||
import { LoginCallback } from './handlers/login-callback';
|
||||
import { Router } from './router';
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.add('GET', 'bot-join', BotJoin);
|
||||
router.add('GET', 'login-bounce', LoginBounce);
|
||||
router.add('GET', 'login-callback', LoginCallback);
|
||||
|
||||
addEventListener('fetch', (event: FetchEvent) => {
|
||||
event.respondWith(router.handle(event.request));
|
||||
});
|
73
src/backend-worker/router.ts
Normal file
73
src/backend-worker/router.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
export type Handler = (request: Request) => Promise<Response> | Response;
|
||||
|
||||
type RoutingTree = {
|
||||
[method: string]: {
|
||||
[path: string]: Handler;
|
||||
};
|
||||
};
|
||||
|
||||
type Fallbacks = {
|
||||
root: Handler;
|
||||
404: Handler;
|
||||
500: Handler;
|
||||
};
|
||||
|
||||
export class Router {
|
||||
private routingTree: RoutingTree = {};
|
||||
private fallbacks: Fallbacks = {
|
||||
root: this.respondToRoot,
|
||||
404: this.notFound,
|
||||
500: this.serverError,
|
||||
};
|
||||
|
||||
addFallback(which: keyof Fallbacks, handler: Handler) {
|
||||
this.fallbacks[which] = handler;
|
||||
}
|
||||
|
||||
add(method: string, rootPath: string, handler: Handler) {
|
||||
const lowerMethod = method.toLowerCase();
|
||||
|
||||
if (!this.routingTree[lowerMethod]) {
|
||||
this.routingTree[lowerMethod] = {};
|
||||
}
|
||||
|
||||
this.routingTree[lowerMethod][rootPath] = handler;
|
||||
}
|
||||
|
||||
handle(request: Request): Promise<Response> | Response {
|
||||
if (request.url === '/') {
|
||||
return this.fallbacks.root(request);
|
||||
}
|
||||
const lowerMethod = request.method.toLowerCase();
|
||||
const url = new URL(request.url);
|
||||
const rootPath = url.pathname.split('/')[1];
|
||||
const handler = this.routingTree[lowerMethod]?.[rootPath];
|
||||
|
||||
if (handler) {
|
||||
try {
|
||||
return handler(request);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return this.fallbacks[500](request);
|
||||
}
|
||||
}
|
||||
|
||||
return this.fallbacks[404](request);
|
||||
}
|
||||
|
||||
private respondToRoot(): Response {
|
||||
return new Response('Hi there!');
|
||||
}
|
||||
|
||||
private notFound(): Response {
|
||||
return new Response(JSON.stringify({ error: 'not_found' }), {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
private serverError(): Response {
|
||||
return new Response(JSON.stringify({ error: 'internal_server_error' }), {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
14
src/backend-worker/tsconfig.json
Normal file
14
src/backend-worker/tsconfig.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"lib": ["esnext", "webworker"],
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
},
|
||||
"include": [
|
||||
"./*.ts",
|
||||
"./**/*.ts",
|
||||
"../../node_modules/@cloudflare/workers-types/index.d.ts"
|
||||
],
|
||||
"exclude": ["./**/*.spec.ts"],
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
7
src/backend-worker/utils/bounce.ts
Normal file
7
src/backend-worker/utils/bounce.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export const Bounce = (url: string): Response =>
|
||||
new Response(null, {
|
||||
status: 303,
|
||||
headers: {
|
||||
location: url,
|
||||
},
|
||||
});
|
29
src/backend-worker/webpack.config.js
Normal file
29
src/backend-worker/webpack.config.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
const path = require('path');
|
||||
|
||||
const mode = process.env.NODE_ENV || 'production';
|
||||
|
||||
module.exports = {
|
||||
target: 'webworker',
|
||||
entry: path.join(__dirname, 'index.ts'),
|
||||
output: {
|
||||
filename: `worker.${mode}.js`,
|
||||
path: path.join(__dirname, 'dist'),
|
||||
},
|
||||
mode,
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js'],
|
||||
plugins: [],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
transpileOnly: true,
|
||||
configFile: path.join(__dirname, 'tsconfig.json'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
0
src/functions/hello-world/helloworld.ts
Normal file
0
src/functions/hello-world/helloworld.ts
Normal file
|
@ -1,156 +0,0 @@
|
|||
// Package redisbreaker provides a go-redis v8 instance designed for resilient caching via circuit breakers.
|
||||
// tl;dr: If redis is lost, it can either cache objects in memory using sync.Map or dropping gracefully.
|
||||
// As a side benefit, it means we don't need a redis server to develop locally, unless we want one :)
|
||||
package redisbreaker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
"github.com/sony/gobreaker"
|
||||
)
|
||||
|
||||
type RedisBreaker struct {
|
||||
redisClient *redis.Client
|
||||
breaker *gobreaker.CircuitBreaker
|
||||
|
||||
config *RedisBreakerConfig
|
||||
inMemoryCache sync.Map
|
||||
}
|
||||
|
||||
type RedisBreakerConfig struct {
|
||||
Redis *redis.Options
|
||||
Breaker gobreaker.Settings
|
||||
UseInMemoryCache bool
|
||||
DefaultTTL time.Duration
|
||||
}
|
||||
|
||||
type inmemoryCacheObject struct {
|
||||
expiresAt time.Time
|
||||
object []byte
|
||||
}
|
||||
|
||||
func NewRedisBreaker(config *RedisBreakerConfig) *RedisBreaker {
|
||||
if config == nil {
|
||||
config = &RedisBreakerConfig{
|
||||
UseInMemoryCache: true,
|
||||
}
|
||||
}
|
||||
|
||||
if config.DefaultTTL == 0 {
|
||||
config.DefaultTTL = 2 * time.Minute
|
||||
}
|
||||
|
||||
breaker := &RedisBreaker{
|
||||
config: config,
|
||||
redisClient: redis.NewClient(config.Redis),
|
||||
breaker: gobreaker.NewCircuitBreaker(config.Breaker),
|
||||
}
|
||||
|
||||
return breaker
|
||||
}
|
||||
|
||||
func (rb *RedisBreaker) doOr(
|
||||
ctx context.Context,
|
||||
func1 func(context.Context, string, interface{}) (interface{}, error),
|
||||
func2 func(context.Context, string, interface{}) (interface{}, error),
|
||||
key string,
|
||||
object interface{},
|
||||
) (interface{}, error) {
|
||||
val, err := rb.breaker.Execute(func() (interface{}, error) {
|
||||
return func1(ctx, key, object)
|
||||
})
|
||||
if err == gobreaker.ErrOpenState || err == gobreaker.ErrTooManyRequests {
|
||||
return func2(ctx, key, object)
|
||||
}
|
||||
|
||||
return val, err
|
||||
}
|
||||
|
||||
// Set pushes an object into the cache with the specified default TTL, using SetEX
|
||||
func (rb *RedisBreaker) Set(ctx context.Context, key string, object interface{}) error {
|
||||
_, err := rb.doOr(ctx, rb.setRedis, rb.setInmemory, key, object)
|
||||
return err
|
||||
}
|
||||
|
||||
func (rb *RedisBreaker) setRedis(ctx context.Context, key string, object interface{}) (interface{}, error) {
|
||||
objectJSON, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rb.redisClient.SetEX(ctx, key, objectJSON, rb.config.DefaultTTL).Result()
|
||||
}
|
||||
|
||||
func (rb *RedisBreaker) setInmemory(ctx context.Context, key string, object interface{}) (interface{}, error) {
|
||||
if rb.config.UseInMemoryCache {
|
||||
objectJSON, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rb.inMemoryCache.Store(key, inmemoryCacheObject{
|
||||
expiresAt: time.Now().Add(rb.config.DefaultTTL),
|
||||
object: objectJSON,
|
||||
})
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Get pulls an object from cache, returning ok = true if it succeeded.
|
||||
func (rb *RedisBreaker) Get(ctx context.Context, key string, object interface{}) (bool, error) {
|
||||
ok, err := rb.doOr(ctx, rb.getRedis, rb.getInmemory, key, object)
|
||||
|
||||
return ok.(bool), err
|
||||
}
|
||||
|
||||
func (rb *RedisBreaker) getRedis(ctx context.Context, key string, object interface{}) (interface{}, error) {
|
||||
result := rb.redisClient.Get(ctx, key)
|
||||
if result.Err() != nil {
|
||||
if result.Err() == redis.Nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, result.Err()
|
||||
}
|
||||
|
||||
objectJSON, err := result.Bytes()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(objectJSON, object)
|
||||
|
||||
return true, err
|
||||
}
|
||||
|
||||
func (rb *RedisBreaker) getInmemory(ctx context.Context, key string, object interface{}) (interface{}, error) {
|
||||
if !rb.config.UseInMemoryCache {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cacheObjIntf, ok := rb.inMemoryCache.Load(key)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cacheObj, ok := cacheObjIntf.(inmemoryCacheObject)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if time.Now().After(cacheObj.expiresAt) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
err := json.Unmarshal(cacheObj.object, object)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
|
@ -1,130 +0,0 @@
|
|||
package redisbreaker_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/go-redis/redis/v8"
|
||||
"github.com/onsi/gomega"
|
||||
"github.com/sony/gobreaker"
|
||||
|
||||
redisbreaker "github.com/roleypoly/roleypoly/src/redis-breaker"
|
||||
)
|
||||
|
||||
func getBreaker(breakerOpen bool) (*redisbreaker.RedisBreaker, *miniredis.Miniredis) {
|
||||
redisServer, err := miniredis.Run()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
config := &redisbreaker.RedisBreakerConfig{
|
||||
Redis: &redis.Options{
|
||||
Addr: redisServer.Addr(),
|
||||
},
|
||||
UseInMemoryCache: true,
|
||||
DefaultTTL: 1 * time.Second,
|
||||
}
|
||||
|
||||
if breakerOpen {
|
||||
config.Breaker.ReadyToTrip = func(gobreaker.Counts) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
redisServer.Close()
|
||||
}
|
||||
|
||||
rb := redisbreaker.NewRedisBreaker(config)
|
||||
|
||||
if breakerOpen {
|
||||
// forcibly open the breaker
|
||||
rb.Set(context.Background(), "@@@breaker@@@", nil)
|
||||
}
|
||||
|
||||
return rb, redisServer
|
||||
}
|
||||
|
||||
type TestData struct {
|
||||
IAmAField1 string
|
||||
IAmAField2 int
|
||||
IAmAField3 map[string]interface{}
|
||||
}
|
||||
|
||||
var testData = TestData{
|
||||
IAmAField1: "hello world!",
|
||||
IAmAField2: 420 * 69,
|
||||
IAmAField3: map[string]interface{}{
|
||||
"foxes": "are so heckin cute",
|
||||
},
|
||||
}
|
||||
|
||||
func getSet(t *testing.T, openCircuit bool) {
|
||||
g := gomega.NewGomegaWithT(t)
|
||||
rb, rds := getBreaker(openCircuit)
|
||||
defer rds.Close()
|
||||
|
||||
err := rb.Set(context.Background(), "test-data", testData)
|
||||
g.Expect(err).To(gomega.BeNil())
|
||||
|
||||
output := TestData{}
|
||||
ok, err := rb.Get(context.Background(), "test-data", &output)
|
||||
g.Expect(err).To(gomega.BeNil())
|
||||
|
||||
g.Expect(ok).To(gomega.BeTrue(), "ok should be true")
|
||||
g.Expect(output).To(gomega.Equal(testData), "testData should match output data")
|
||||
}
|
||||
|
||||
func TestGetSet(t *testing.T) {
|
||||
getSet(t, false)
|
||||
}
|
||||
|
||||
func TestGetSetOpenCircuit(t *testing.T) {
|
||||
getSet(t, true)
|
||||
}
|
||||
|
||||
func getNotInCache(t *testing.T, openCircuit bool) {
|
||||
g := gomega.NewGomegaWithT(t)
|
||||
rb, rds := getBreaker(openCircuit)
|
||||
defer rds.Close()
|
||||
|
||||
output := TestData{}
|
||||
ok, err := rb.Get(context.Background(), "not-test-data", &output)
|
||||
g.Expect(err).To(gomega.BeNil())
|
||||
|
||||
g.Expect(ok).To(gomega.BeFalse(), "ok should be false")
|
||||
g.Expect(output).To(gomega.BeZero(), "output should be 'zero'")
|
||||
}
|
||||
|
||||
func TestGetNotInCache(t *testing.T) {
|
||||
getNotInCache(t, false)
|
||||
}
|
||||
|
||||
func TestGetNotInCacheOpenCircuit(t *testing.T) {
|
||||
getNotInCache(t, true)
|
||||
}
|
||||
|
||||
func getAfterTTL(t *testing.T, openCircuit bool) {
|
||||
g := gomega.NewGomegaWithT(t)
|
||||
rb, rds := getBreaker(openCircuit)
|
||||
defer rds.Close()
|
||||
|
||||
err := rb.Set(context.Background(), "test-expired", testData)
|
||||
g.Expect(err).To(gomega.BeNil())
|
||||
|
||||
rds.FastForward(1 * time.Second)
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
output := TestData{}
|
||||
ok, err := rb.Get(context.Background(), "test-expired", &output)
|
||||
g.Expect(ok).To(gomega.BeFalse(), "ok should be false")
|
||||
g.Expect(output).To(gomega.BeZero(), "output should be 'zero'")
|
||||
}
|
||||
|
||||
func TestGetAfterTTL(t *testing.T) {
|
||||
getAfterTTL(t, false)
|
||||
}
|
||||
|
||||
func TestGetAfterTTLOpenCircuit(t *testing.T) {
|
||||
getAfterTTL(t, true)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue