WIP saurpc integration

This commit is contained in:
Daniel Flanagan 2024-03-10 16:06:38 -05:00
parent 861c4a4035
commit 7825dda25e
12 changed files with 164 additions and 22 deletions

2
.gitignore vendored
View file

@ -15,6 +15,8 @@ node_modules/
*.db *.db
*.db-wal *.db-wal
*.db-shm *.db-shm
*-wal
*-shm
/static/uploads /static/uploads

9
TablesForModels.ts Normal file
View file

@ -0,0 +1,9 @@
import { Display, Playlist, Song, Team, User } from '@lyrics/models.ts'
export const TablesForModels = {
'song': Song,
'playlist': Playlist,
'display': Display,
'user': User,
'team': Team,
}

6
api.ts
View file

@ -2,6 +2,12 @@ import { FreshContext, Handlers } from '$fresh/server.ts'
import { db, TablesForModels } from '@lyrics/db.ts' import { db, TablesForModels } from '@lyrics/db.ts'
import { ulid } from 'https://deno.land/std@0.209.0/ulid/mod.ts' import { ulid } from 'https://deno.land/std@0.209.0/ulid/mod.ts'
export type Method = 'GET' | 'PUT' | 'DELETE'
export function isMethod(value: string): value is Method {
return (value in TablesForModels)
}
export function crudHandlerFor< export function crudHandlerFor<
T extends { id: string }, T extends { id: string },
M extends { parse: (a: unknown) => T }, M extends { parse: (a: unknown) => T },

13
db.ts
View file

@ -1,4 +1,4 @@
import { Display, Playlist, Song } from '@lyrics/models.ts' import { AuthUser, Display, Playlist, Song, Team } from '@lyrics/models.ts'
export const kv = await Deno.openKv('lyrics') export const kv = await Deno.openKv('lyrics')
@ -6,12 +6,14 @@ export const TablesForModels = {
'song': Song, 'song': Song,
'playlist': Playlist, 'playlist': Playlist,
'display': Display, 'display': Display,
'user': AuthUser,
'team': Team,
} }
export type Table = keyof typeof TablesForModels export type Table = keyof typeof TablesForModels
function crudFor< function crudFor<
P extends keyof typeof TablesForModels,
T extends { id: string }, T extends { id: string },
P extends keyof typeof TablesForModels,
M extends { parse: (a: unknown) => T }, M extends { parse: (a: unknown) => T },
>(m: M, p: P) { >(m: M, p: P) {
return { return {
@ -42,6 +44,11 @@ export function isTable(value: string): value is Table {
export const db = { export const db = {
song: crudFor(Song, 'song'), song: crudFor(Song, 'song'),
playlist: crudFor(Playlist, 'playlist'), playlist: crudFor(
Playlist,
'playlist',
),
display: crudFor(Display, 'display'), display: crudFor(Display, 'display'),
user: crudFor(AuthUser, 'user'),
team: crudFor(Team, 'team'),
} }

View file

@ -21,16 +21,18 @@
"**/_fresh/*" "**/_fresh/*"
], ],
"imports": { "imports": {
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts",
"saurpc": "https://deno.land/x/saurpc@0.5.0/mod.ts",
"bcrypt": "https://deno.land/x/bcrypt@v0.4.1/mod.ts",
"$fresh/": "https://deno.land/x/fresh@1.6.5/", "$fresh/": "https://deno.land/x/fresh@1.6.5/",
"preact": "https://esm.sh/preact@10.19.2", "preact": "https://esm.sh/preact@10.19.2",
"preact/": "https://esm.sh/preact@10.19.2/", "preact/": "https://esm.sh/preact@10.19.2/",
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.1", "@preact/signals": "https://esm.sh/*@preact/signals@1.2.1",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.0", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.0",
"tailwindcss": "npm:tailwindcss@3.4.1", "tailwindcss": "npm:tailwindcss@3.4.1",
"tailwindcss/": "npm:/tailwindcss@3.4.1/",
"tailwindcss/plugin": "npm:/tailwindcss@3.4.1/plugin.js", "tailwindcss/plugin": "npm:/tailwindcss@3.4.1/plugin.js",
"@lyrics/": "./", "@lyrics/": "./",
"$std/": "https://deno.land/std@0.211.0/" "$std/": "https://deno.land/std@0.219.0/"
}, },
"compilerOptions": { "compilerOptions": {
"jsx": "react-jsx", "jsx": "react-jsx",

View file

@ -2,16 +2,16 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1708118438, "lastModified": 1710085324,
"narHash": "sha256-kk9/0nuVgA220FcqH/D2xaN6uGyHp/zoxPNUmPCMmEE=", "narHash": "sha256-OV+dGVjOFuGrdutv+7SFNf+8ArJE3QuUKPKeRziZVVY=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "5863c27340ba4de8f83e7e3c023b9599c3cb3c80", "rev": "91686dd2ff9e091f5010241de9d1de0a9388c7c8",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "NixOS",
"ref": "nixos-unstable", "ref": "master",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }

View file

@ -1,5 +1,5 @@
{ {
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/master";
outputs = { outputs = {
self, self,
nixpkgs, nixpkgs,

View file

@ -7,6 +7,7 @@ import * as $_app from './routes/_app.tsx'
import * as $api_db from './routes/api/db.ts' import * as $api_db from './routes/api/db.ts'
import * as $api_db_table_ from './routes/api/db/[table].ts' import * as $api_db_table_ from './routes/api/db/[table].ts'
import * as $api_joke from './routes/api/joke.ts' import * as $api_joke from './routes/api/joke.ts'
import * as $api_rpc from './routes/api/rpc.ts'
import * as $greet_name_ from './routes/greet/[name].tsx' import * as $greet_name_ from './routes/greet/[name].tsx'
import * as $index from './routes/index.tsx' import * as $index from './routes/index.tsx'
import * as $Counter from './islands/Counter.tsx' import * as $Counter from './islands/Counter.tsx'
@ -19,6 +20,7 @@ const manifest = {
'./routes/api/db.ts': $api_db, './routes/api/db.ts': $api_db,
'./routes/api/db/[table].ts': $api_db_table_, './routes/api/db/[table].ts': $api_db_table_,
'./routes/api/joke.ts': $api_joke, './routes/api/joke.ts': $api_joke,
'./routes/api/rpc.ts': $api_rpc,
'./routes/greet/[name].tsx': $greet_name_, './routes/greet/[name].tsx': $greet_name_,
'./routes/index.tsx': $index, './routes/index.tsx': $index,
}, },

View file

@ -1,10 +1,49 @@
import { z } from 'https://deno.land/x/zod@v3.22.4/mod.ts' import { z } from 'zod'
// shared
export const Identifiable = z.object({ export const Identifiable = z.object({
id: z.string().uuid(), id: z.string().ulid(),
}) })
export type TIdentifiable = z.infer<typeof Identifiable> export type TIdentifiable = z.infer<typeof Identifiable>
export const Creatable = z.object({
createdAt: z.date(),
})
export type TCreatable = z.infer<typeof Creatable>
export const Updatable = z.object({
updatedAt: z.date(),
})
export type TUpdatable = z.infer<typeof Updatable>
// lyrics
export const Role = z.enum([
'admin', // full permissions
'editor', // edit songs, verses, displays, playlists, etc.
'viewer', // cannot change anything, but can view everything
])
export type TRole = z.infer<typeof Role>
export const Team = Identifiable.merge(Creatable).merge(z.object({
name: z.string(),
members: z.record(z.string().ulid(), z.set(Role)),
}))
export type TTeam = z.infer<typeof Team>
export const User = Identifiable.merge(Creatable).merge(z.object({
username: z.string(),
}))
export type TUser = z.infer<typeof User>
export const AuthUser = User.merge(z.object({
passwordDigest: z.string(),
}))
export type TAuthUser = z.infer<typeof AuthUser>
export function toUser({ passwordDigest: _, ...user }: TAuthUser): TUser {
return user
}
export const Verse = z.object({ export const Verse = z.object({
content: z.string(), content: z.string(),
}) })
@ -16,25 +55,27 @@ export const Map = z.object({
export type TMap = z.infer<typeof Map> export type TMap = z.infer<typeof Map>
export const Song = Identifiable.merge(z.object({ export const Song = Identifiable.merge(z.object({
title: z.string(), name: z.string(),
verses: z.record(z.string(), Verse), verses: z.record(z.string(), Verse),
maps: z.record(z.string(), Map), maps: z.record(z.string(), Map),
})) }))
export type TSong = z.infer<typeof Song> export type TSong = z.infer<typeof Song>
export const PlaylistEntry = z.object({ export const PlaylistEntry = z.object({
songKey: z.string().uuid(), songId: z.string().ulid(),
mapKey: z.string(), mapKey: z.string(),
}) })
export type TPlaylistEntry = z.infer<typeof PlaylistEntry> export type TPlaylistEntry = z.infer<typeof PlaylistEntry>
export const Playlist = Identifiable.merge(z.object({ export const Playlist = Identifiable.merge(z.object({
name: z.string(),
entries: z.array(PlaylistEntry), entries: z.array(PlaylistEntry),
})) }))
export type TPlaylist = z.infer<typeof Playlist> export type TPlaylist = z.infer<typeof Playlist>
export const Display = Identifiable.merge(z.object({ export const Display = Identifiable.merge(z.object({
playlistKey: z.string().uuid(), name: z.string(),
songKeyIndex: z.number(), playlistId: z.string().ulid(),
songIndex: z.number(),
})) }))
export type TDisplay = z.infer<typeof Display> export type TDisplay = z.infer<typeof Display>

View file

@ -1,5 +1,4 @@
import { FreshContext, Handlers } from '$fresh/server.ts' import { FreshContext } from '$fresh/server.ts'
import { knownMethods } from '$fresh/server/router.ts'
import { isTable } from '@lyrics/db.ts' import { isTable } from '@lyrics/db.ts'
import { crudHandlerFor } from '@lyrics/api.ts' import { crudHandlerFor } from '@lyrics/api.ts'
import { Display, Playlist, Song } from '@lyrics/models.ts' import { Display, Playlist, Song } from '@lyrics/models.ts'
@ -10,10 +9,6 @@ export const subHandlers = {
display: crudHandlerFor(Display, 'display'), display: crudHandlerFor(Display, 'display'),
} }
export function isMethod(value: string): value is Table {
return (value in [])
}
export const handler = (req: Request, ctx: FreshContext): Response => { export const handler = (req: Request, ctx: FreshContext): Response => {
const table = ctx.params.table const table = ctx.params.table
req.method req.method

51
routes/api/rpc.ts Normal file
View file

@ -0,0 +1,51 @@
// import { FreshContext } from '$fresh/server.ts'
import { TablesForModels } from '@lyrics/db.ts'
import { handleRpcRequest, ProcedureServerError } from 'saurpc'
import { rpcs } from '@lyrics/rpc.ts'
export type Method = 'GET' | 'PUT' | 'DELETE'
export function isMethod(value: string): value is Method {
return (value in TablesForModels)
}
function toErrorResponse(e: ProcedureServerError) {
const { type, message } = e
const headers = { 'content-type': 'application/json' }
switch (type) {
case 'rpc_not_found':
console.info('RPC Not Found:', e)
return new Response(
JSON.stringify({ type, message }),
{ status: 404, headers },
)
case 'invalid_request':
console.info('Invalid RPC:', e)
return new Response(
JSON.stringify({ type, message }),
{ status: 400, headers },
)
case 'exception_caught':
console.error('RPC Exception:', e)
return new Response(
JSON.stringify({ type }),
{ status: 500, headers },
)
}
}
export const handler = async (
req: Request,
// _ctx: FreshContext,
): Promise<Response> => {
try {
return await handleRpcRequest(req, rpcs)
} catch (e) {
if (e instanceof ProcedureServerError) {
return toErrorResponse(e)
} else {
console.error('Error handling RPC request:', e)
return new Response('unknown error occurred', { status: 500 })
}
}
}

27
rpc.ts Normal file
View file

@ -0,0 +1,27 @@
import { hash } from 'bcrypt'
// import { User } from './models.ts'
import { TAuthUser, toUser, TUser } from '@lyrics/models.ts'
import { ulid } from '$std/ulid/mod.ts'
import { db } from '@lyrics/db.ts'
export const rpcs = {
'ping': (): 'pong' => {
console.log('received ping')
return 'pong'
},
'getVersion': () => '0.1.0',
async registerUser(
username: string,
password: string,
): Promise<TUser> {
const user: TAuthUser = {
id: ulid(),
createdAt: new Date(),
username: username,
passwordDigest: await hash(password),
}
const result = await db.user.save(user)
if (!result.ok) throw new Error('failed to save user')
return toUser(user)
},
}