Compare commits

...

61 Commits

Author SHA1 Message Date
Julien Valverdé
f4366c89fd Package upgrade 2024-07-21 01:30:07 +02:00
Julien Valverdé
58d307de14 Cleanup 2024-07-21 01:08:35 +02:00
Julien Valverdé
8ab38af883 Fix 2024-07-20 05:00:41 +02:00
Julien Valverdé
15f89974cf Cleanup 2024-07-20 04:38:03 +02:00
Julien Valverdé
2e867f9da5 tsconfig 2024-07-20 03:21:34 +02:00
Julien Valverdé
8f7617b5a3 Fix 2024-07-20 01:48:47 +02:00
Julien Valverdé
35dc1b2b44 UI work 2024-07-20 01:39:39 +02:00
Julien Valverdé
0e4950a3cd Radix UI setup 2024-07-20 01:00:05 +02:00
Julien Valverdé
1170083e49 ShadCN setup 2024-07-19 23:38:15 +02:00
Julien Valverdé
ad9951947a Button 2024-07-19 02:05:47 +02:00
Julien Valverdé
7c1d6c4e83 tsconfig 2024-07-19 02:00:32 +02:00
Julien Valverdé
95cbd2a20c Fix 2024-07-19 01:48:01 +02:00
Julien Valverdé
e1cbe27c56 Fix 2024-07-19 01:26:32 +02:00
Julien Valverdé
ea2fc9f23f ShadCN setup 2024-07-19 01:06:29 +02:00
Julien Valverdé
061272ee0c Fix 2024-07-19 00:56:30 +02:00
Julien Valverdé
c033aa0bed WebUI paths setup 2024-07-19 00:55:47 +02:00
Julien Valverdé
7729bff903 Docker compose fix 2024-07-18 23:50:39 +02:00
Julien Valverdé
e154d4a88e Docker compose work 2024-07-18 05:57:00 +02:00
Julien Valverdé
6bc20bd455 Cleanup 2024-07-18 05:54:55 +02:00
Julien Valverdé
2c2a867c80 SIGKILL Vite 2024-07-18 05:54:10 +02:00
Julien Valverdé
00aa9fe10f Animation work 2024-07-18 03:19:24 +02:00
Julien Valverdé
d07ba927f9 Working AnimatedOutlet 2024-07-18 03:10:14 +02:00
Julien Valverdé
0b695506cf About page for tests 2024-07-18 02:21:46 +02:00
Julien Valverdé
99d192bc43 TRPCClientProvider refactoring 2024-07-18 01:51:42 +02:00
Julien Valverdé
5ebf7f4a6e Fix 2024-07-18 01:22:16 +02:00
Julien Valverdé
4315d0dc52 Fix 2024-07-18 00:39:12 +02:00
Julien Valverdé
ddb531a521 HTTP server fix 2024-07-17 23:29:15 +02:00
Julien Valverdé
af7d944116 Fix 2024-07-17 21:45:32 +02:00
Julien Valverdé
0d792ad8be ServerTime 2024-07-17 07:26:57 +02:00
Julien Valverdé
d364eba805 watch instead of hot 2024-07-17 07:08:31 +02:00
Julien Valverdé
8579f3b7cd Server hot reload 2024-07-17 07:00:57 +02:00
Julien Valverdé
c3560c41bc Cleanup 2024-07-17 05:15:01 +02:00
Julien Valverdé
a0bf11466d Fixed reverse proxy websockets 2024-07-17 05:07:23 +02:00
Julien Valverdé
ec5cc51f82 RPCWebSocketServer 2024-07-17 04:51:41 +02:00
Julien Valverdé
552483d915 Fixed 2024-07-17 04:06:50 +02:00
Julien Valverdé
77c0141154 Fixed? 2024-07-17 03:59:26 +02:00
Julien Valverdé
23ccc1da28 serverTime subscription 2024-07-17 03:45:59 +02:00
Julien Valverdé
52f7c5cc4b WS Debugging 2024-07-17 03:26:44 +02:00
Julien Valverdé
af9baa3e9a Trying to fix reverse proxy for websockets 2024-07-17 02:32:46 +02:00
Julien Valverdé
2daf60faf3 Working reverse proxy 2024-07-17 01:43:21 +02:00
Julien Valverdé
2497aaa236 Docker setup work 2024-07-16 04:14:30 +02:00
Julien Valverdé
af2329105d Docker compose file 2024-07-16 03:55:34 +02:00
Julien Valverdé
0be4e0d8ce Fix 2024-07-15 05:12:27 +02:00
Julien Valverdé
f4eeb66459 Config refactoring 2024-07-15 05:01:24 +02:00
Julien Valverdé
000c5bda35 Mode config 2024-07-15 04:18:54 +02:00
Julien Valverdé
19f852fba5 Error tests 2024-07-15 02:43:39 +02:00
Julien Valverdé
d2734fa71b Error logging 2024-07-15 02:27:44 +02:00
Julien Valverdé
3ae5f31f96 Fix 2024-07-15 02:22:40 +02:00
Julien Valverdé
63f636f231 tRPC context work 2024-07-15 02:06:17 +02:00
Julien Valverdé
5a3400f6dd TRCPErrorCause 2024-07-14 07:47:54 +02:00
Julien Valverdé
c001738f46 Context adapter work 2024-07-14 04:31:13 +02:00
Julien Valverdé
69d9ffb3e0 Context transaction 2024-07-14 03:11:44 +02:00
Julien Valverdé
50b525cfd2 Handlers 2024-07-14 02:05:22 +02:00
Julien Valverdé
d053c61eab TRPCContextRequest 2024-07-13 05:46:30 +02:00
Julien Valverdé
2a909a34bc Fix 2024-07-13 05:19:46 +02:00
Julien Valverdé
329b9e7c41 Todo buttons work 2024-07-13 04:44:52 +02:00
Julien Valverdé
e5641924f8 Todo update work 2024-07-13 04:21:54 +02:00
Julien Valverdé
44909f256e Todo router fix 2024-07-13 04:09:46 +02:00
Julien Valverdé
0b740d6c7f todoRouter 2024-07-13 03:37:36 +02:00
Julien Valverdé
a27ff5834e Update Todo work 2024-07-13 03:29:45 +02:00
Julien Valverdé
9a382a80ff Update Todo work 2024-07-13 03:29:36 +02:00
41 changed files with 704 additions and 226 deletions

33
Caddyfile Normal file
View File

@@ -0,0 +1,33 @@
(reverse_proxy_headers) {
header_up Host {upstream_hostport}
}
# App entrypoint
http://* {
# Redirect RPC calls (both HTTP and WebSockets) to the backend
handle /rpc* {
reverse_proxy http://server {
import reverse_proxy_headers
}
}
# Or serve the UI
handle {
reverse_proxy http://webui {
import reverse_proxy_headers
}
}
}
http://server.* {
reverse_proxy http://server {
import reverse_proxy_headers
}
}
http://webui.* {
reverse_proxy http://webui {
import reverse_proxy_headers
}
}

BIN
bun.lockb

Binary file not shown.

49
docker-compose.yml Normal file
View File

@@ -0,0 +1,49 @@
x-service-base: &service-base
user: ${UID:?UID missing}:${GID:?GID missing}
tty: true
x-volume-app: &volume-app ./:/app/
x-env-base: &env-base
TZ: ${TZ:?TZ missing}
services:
reverse-proxy:
container_name: reverse-proxy
image: caddy:latest
ports:
- ${PORT:?PORT missing}:80
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
environment:
<<: *env-base
server:
<<: *service-base
container_name: server
image: oven/bun:latest
volumes:
- *volume-app
working_dir: /app/packages/server
env_file: .env
environment:
<<: *env-base
NODE_ENV: development
entrypoint: ["bun", "--watch", "src/index.ts"]
depends_on:
- reverse-proxy
webui:
<<: *service-base
container_name: webui
image: oven/bun:latest
volumes:
- *volume-app
working_dir: /app/packages/webui
environment:
<<: *env-base
entrypoint: ["bun", "vite"]
stop_signal: SIGKILL
depends_on:
- reverse-proxy

View File

@@ -8,8 +8,8 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@effect/schema": "^0.68.17", "@effect/schema": "^0.68.26",
"@thilawyn/thilalib": "^0.1.5", "@thilawyn/thilalib": "^0.1.5",
"effect": "^3.4.7" "effect": "^3.5.6"
} }
} }

View File

@@ -0,0 +1,4 @@
import { Schema as S } from "@effect/schema"
export const ServerTime = S.DateFromString

View File

@@ -1 +1,2 @@
export * from "./ServerTime.type"
export * from "./Todo.class" export * from "./Todo.class"

View File

@@ -14,12 +14,12 @@ export module Identifiable {
Kind extends string, Kind extends string,
ID, ID,
>( >(
self: Identifiable<Kind, ID>,
that: Identifiable<Kind, ID>, that: Identifiable<Kind, ID>,
to: Identifiable<Kind, ID>,
): boolean { ): boolean {
// Two elements can only be equal if they both have a defined ID // Two elements can only be equal if they both have a defined ID
return Option.isSome(that.id) && Option.isSome(to.id) return Option.isSome(self.id) && Option.isSome(that.id)
? Equal.equals(that.id.value, to.id.value) ? Equal.equals(self.id.value, that.id.value)
: false : false
} }
} }

View File

@@ -5,20 +5,23 @@
".": "./src/router.ts" ".": "./src/router.ts"
}, },
"private": true, "private": true,
"scripts": {
"dev": "NODE_ENV=development bun --watch src/index.ts"
},
"dependencies": { "dependencies": {
"@effect/platform": "^0.58.20", "@effect/platform": "^0.59.2",
"@effect/platform-bun": "^0.38.19", "@effect/platform-bun": "^0.39.2",
"@effect/schema": "^0.68.17", "@effect/schema": "^0.68.26",
"@thilawyn/thilalib": "^0.1.5", "@thilawyn/thilalib": "^0.1.5",
"@todo-tests/common": "workspace:*", "@todo-tests/common": "workspace:*",
"@trpc/server": "^10.45.2", "@trpc/server": "^10.45.2",
"effect": "^3.4.7", "effect": "^3.5.6",
"express": "^4.19.2", "express": "^4.19.2",
"trpc-playground": "^1.0.4", "trpc-playground": "^1.0.4",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"bun-types": "^1.1.18" "bun-types": "^1.1.20"
} }
} }

View File

@@ -0,0 +1,20 @@
import { Schema as S } from "@effect/schema"
import { Config } from "effect"
export module ServerConfig {
export const mode = Config.string("NODE_ENV").pipe(
Config.validate({
validation: S.is(S.Union(
S.Literal("development"),
S.Literal("production"),
)),
message: "Expected 'development' or 'production'",
}),
Config.withDefault("development"),
)
export const httpPort = Config.number("HTTP_PORT").pipe(Config.withDefault(80))
export const rpcHTTPRoot = Config.string("RPC_HTTP_ROOT").pipe(Config.withDefault("/rpc"))
export const rpcHTTPPlaygroundRoot = Config.string("RPC_HTTP_PLAYGROUND_ROOT").pipe(Config.withDefault("/rpc/playground"))
}

View File

@@ -8,11 +8,11 @@ type TServices =
export interface Services extends TServices {} export interface Services extends TServices {}
export module Services { export module Services {
export const Live = Layer.mergeAll( export const Live: Layer.Layer<Services, never, never> = Layer.mergeAll(
TodoRepository.Live TodoRepository.Live
) satisfies Layer.Layer<Services, never, never> )
export const Dev = Layer.mergeAll( export const Dev: Layer.Layer<Services, never, never> = Layer.mergeAll(
TodoRepository.Live TodoRepository.Live
) satisfies Layer.Layer<Services, never, never> )
} }

View File

@@ -1,6 +0,0 @@
import { Config } from "effect"
export const httpPort = Config.number("HTTP_PORT").pipe(Config.withDefault(8080))
export const rpcHTTPRoot = Config.string("RPC_HTTP_ROOT").pipe(Config.withDefault("/rpc"))
export const rpcHTTPPlaygroundRoot = Config.string("RPC_HTTP_PLAYGROUND_ROOT").pipe(Config.withDefault("/rpc/playground"))

View File

@@ -1,6 +1,6 @@
import { Context, Effect, Layer, Runtime } from "effect" import { Context, Effect, Layer } from "effect"
import { Server } from "node:http" import { Server } from "node:http"
import { httpPort } from "../config" import { ServerConfig } from "../ServerConfig"
import { ExpressApp } from "./ExpressApp" import { ExpressApp } from "./ExpressApp"
@@ -9,14 +9,16 @@ export class ExpressHTTPServer extends Context.Tag("ExpressHTTPServer")<ExpressH
export module ExpressHTTPServer { export module ExpressHTTPServer {
export const Live = Layer.effect(ExpressHTTPServer, Effect.acquireRelease( export const Live = Layer.effect(ExpressHTTPServer, Effect.acquireRelease(
Effect.gen(function*() { Effect.gen(function*() {
const runSync = yield* Effect.runtime().pipe(
Effect.map(Runtime.runSync)
)
const app = yield* ExpressApp const app = yield* ExpressApp
const port = yield* httpPort const port = yield* ServerConfig.httpPort
return app.listen(port, () => runSync(Effect.logInfo(`HTTP server listening on ${ port }`))) return yield* Effect.async<Server>(resume => {
const server = app.listen(port, () => resume(
Effect.succeed(server).pipe(
Effect.tap(Effect.logInfo(`HTTP server listening on ${ port }`))
)
))
})
}), }),
server => Effect.gen(function*() { server => Effect.gen(function*() {

View File

@@ -1,22 +0,0 @@
import { Context, Effect, Layer } from "effect"
import ws from "ws"
import { ExpressHTTPServer } from "../http/ExpressHTTPServer"
export class WebSocketServer extends Context.Tag("WebSocketServer")<WebSocketServer, ws.Server>() {}
export module WebSocketServer {
export const Live = Layer.effect(WebSocketServer, Effect.acquireRelease(
Effect.gen(function*() {
yield* Effect.logInfo("WebSocket server started")
return new ws.WebSocketServer({ server: yield* ExpressHTTPServer })
}),
wss => Effect.gen(function*() {
yield* Effect.logInfo("WebSocket server is stopping. Waiting for existing connections to end...")
yield* Effect.async(resume => {
wss.close(() => resume(Effect.logInfo("WebSocket server closed")))
})
}),
))
}

View File

@@ -1,14 +1,14 @@
import { BunRuntime } from "@effect/platform-bun" import { BunRuntime } from "@effect/platform-bun"
import { Todo } from "@todo-tests/common/data" import { Todo } from "@todo-tests/common/data"
import { Duration, Effect, Layer, Option } from "effect" import { Duration, Effect, Layer, Logger, Match, Option } from "effect"
import { ServerConfig } from "./ServerConfig"
import { Services } from "./Services" import { Services } from "./Services"
import { ExpressApp } from "./http/ExpressApp" import { ExpressApp } from "./http/ExpressApp"
import { ExpressHTTPServer } from "./http/ExpressHTTPServer" import { ExpressHTTPServer } from "./http/ExpressHTTPServer"
import { WebSocketServer } from "./http/WebSocketServer"
import { RPCPlaygroundRoute } from "./rpc/RPCPlaygroundRoute" import { RPCPlaygroundRoute } from "./rpc/RPCPlaygroundRoute"
import { RPCRoute } from "./rpc/RPCRoute" import { RPCRoute } from "./rpc/RPCRoute"
import { RPCRouter } from "./rpc/RPCRouter" import { RPCRouter } from "./rpc/RPCRouter"
import { RPCWebSocketHandler } from "./rpc/RPCWebSocketHandler" import { RPCWebSocketServer } from "./rpc/RPCWebSocketServer"
import { RPCProcedureBuilder } from "./rpc/procedures/RPCProcedureBuilder" import { RPCProcedureBuilder } from "./rpc/procedures/RPCProcedureBuilder"
import { TodoRepository } from "./todo/TodoRepository" import { TodoRepository } from "./todo/TodoRepository"
import { TRPCBuilder } from "./trpc/TRPCBuilder" import { TRPCBuilder } from "./trpc/TRPCBuilder"
@@ -18,22 +18,45 @@ import { TRPCContextCreator } from "./trpc/TRPCContextCreator"
const ServerDev = Layer.empty.pipe( const ServerDev = Layer.empty.pipe(
Layer.provideMerge(RPCRoute.Live), Layer.provideMerge(RPCRoute.Live),
Layer.provideMerge(RPCPlaygroundRoute.Dev), Layer.provideMerge(RPCPlaygroundRoute.Dev),
Layer.provideMerge(RPCWebSocketHandler.Live), Layer.provideMerge(RPCWebSocketServer.Live),
Layer.provideMerge(RPCRouter.Live),
Layer.provideMerge(RPCProcedureBuilder.Live),
Layer.provideMerge(TRPCBuilder.Live),
Layer.provideMerge(TRPCContextCreator.Live),
Layer.provideMerge(ExpressHTTPServer.Live),
Layer.provideMerge(ExpressApp.Live),
)
const ServerLive = Layer.empty.pipe(
Layer.provideMerge(RPCRoute.Live),
Layer.provideMerge(RPCPlaygroundRoute.Live),
Layer.provideMerge(RPCWebSocketServer.Live),
Layer.provideMerge(RPCRouter.Live), Layer.provideMerge(RPCRouter.Live),
Layer.provideMerge(RPCProcedureBuilder.Live), Layer.provideMerge(RPCProcedureBuilder.Live),
Layer.provideMerge(TRPCBuilder.Live), Layer.provideMerge(TRPCBuilder.Live),
Layer.provideMerge(TRPCContextCreator.Live), Layer.provideMerge(TRPCContextCreator.Live),
Layer.provideMerge(WebSocketServer.Live),
Layer.provideMerge(ExpressHTTPServer.Live), Layer.provideMerge(ExpressHTTPServer.Live),
Layer.provideMerge(ExpressApp.Live), Layer.provideMerge(ExpressApp.Live),
) )
const main = Effect.gen(function*() { const main = Effect.gen(function*() {
const mode = yield* ServerConfig.mode
const todos = yield* TodoRepository const todos = yield* TodoRepository
// yield* Effect.fork(
// todos.todos.changes.pipe(
// Stream.runForEach(values => Effect.gen(function*() {
// yield* Console.log("Todos updated:")
// yield* Console.log(values)
// }))
// )
// )
yield* todos.add(new Todo({ yield* todos.add(new Todo({
id: Option.none(), id: Option.none(),
order: 0, order: 0,
@@ -79,10 +102,15 @@ const main = Effect.gen(function*() {
) )
yield* Layer.launch(ServerDev) return yield* Layer.launch(Match.value(mode).pipe(
Match.when("development", () => ServerDev),
Match.when("production", () => ServerLive),
Match.exhaustive,
))
}) })
BunRuntime.runMain(main.pipe( BunRuntime.runMain(main.pipe(
Effect.provide(Services.Dev), Effect.provide(Services.Dev),
Effect.provide(Logger.structured),
Effect.scoped, Effect.scoped,
)) ))

View File

@@ -1,6 +1,6 @@
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { expressHandler } from "trpc-playground/handlers/express" import { expressHandler } from "trpc-playground/handlers/express"
import { rpcHTTPPlaygroundRoot, rpcHTTPRoot } from "../config" import { ServerConfig } from "../ServerConfig"
import { ExpressApp } from "../http/ExpressApp" import { ExpressApp } from "../http/ExpressApp"
import { RPCRouter } from "./RPCRouter" import { RPCRouter } from "./RPCRouter"
@@ -10,10 +10,10 @@ export module RPCPlaygroundRoute {
export const Dev = Layer.effectDiscard(Effect.gen(function*() { export const Dev = Layer.effectDiscard(Effect.gen(function*() {
const app = yield* ExpressApp const app = yield* ExpressApp
const playgroundEndpoint = yield* rpcHTTPPlaygroundRoot const playgroundEndpoint = yield* ServerConfig.rpcHTTPPlaygroundRoot
const handler = expressHandler({ const handler = expressHandler({
trpcApiEndpoint: yield* rpcHTTPRoot, trpcApiEndpoint: yield* ServerConfig.rpcHTTPRoot,
playgroundEndpoint, playgroundEndpoint,
router: yield* RPCRouter, router: yield* RPCRouter,
}) })

View File

@@ -1,6 +1,6 @@
import { createExpressMiddleware } from "@trpc/server/adapters/express" import { createExpressMiddleware } from "@trpc/server/adapters/express"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { rpcHTTPRoot } from "../config" import { ServerConfig } from "../ServerConfig"
import { ExpressApp } from "../http/ExpressApp" import { ExpressApp } from "../http/ExpressApp"
import { TRPCContextCreator } from "../trpc/TRPCContextCreator" import { TRPCContextCreator } from "../trpc/TRPCContextCreator"
import { RPCRouter } from "./RPCRouter" import { RPCRouter } from "./RPCRouter"
@@ -10,10 +10,10 @@ export module RPCRoute {
export const Live = Layer.effectDiscard(Effect.gen(function*() { export const Live = Layer.effectDiscard(Effect.gen(function*() {
const app = yield* ExpressApp const app = yield* ExpressApp
app.use(yield* rpcHTTPRoot, app.use(yield* ServerConfig.rpcHTTPRoot,
createExpressMiddleware({ createExpressMiddleware({
router: yield* RPCRouter, router: yield* RPCRouter,
createContext: yield* TRPCContextCreator, createContext: (yield* TRPCContextCreator).createExpressContext,
}), }),
) )
})) }))

View File

@@ -1,27 +1,12 @@
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { TRPCBuilder } from "../trpc/TRPCBuilder" import { indexRouter } from "./routers"
import { RPCProcedureBuilder } from "./procedures/RPCProcedureBuilder"
import { todosRouter } from "./routers/todos"
export const router = Effect.gen(function*() {
const t = yield* TRPCBuilder
const procedure = yield* RPCProcedureBuilder
return t.router({
ping: procedure.query(({ ctx }) =>
ctx.run(Effect.succeed("pong"))
),
todos: yield* todosRouter,
})
})
export class RPCRouter extends Context.Tag("RPCRouter")<RPCRouter, export class RPCRouter extends Context.Tag("RPCRouter")<RPCRouter,
Effect.Effect.Success<typeof router> Effect.Effect.Success<typeof indexRouter>
>() {} >() {}
export module RPCRouter { export module RPCRouter {
export type Router = Context.Tag.Service<RPCRouter> export type Router = Context.Tag.Service<RPCRouter>
export const Live = Layer.effect(RPCRouter, router) export const Live = Layer.effect(RPCRouter, indexRouter)
} }

View File

@@ -1,22 +0,0 @@
import { applyWSSHandler } from "@trpc/server/adapters/ws"
import { Effect, Layer } from "effect"
import { WebSocketServer } from "../http/WebSocketServer"
import { TRPCContextCreator } from "../trpc/TRPCContextCreator"
import { RPCRouter } from "./RPCRouter"
export module RPCWebSocketHandler {
export const Live = Layer.effectDiscard(Effect.acquireRelease(
Effect.gen(function*() {
return applyWSSHandler({
wss: yield* WebSocketServer,
router: yield* RPCRouter,
createContext: yield* TRPCContextCreator,
})
}),
handler => Effect.sync(() =>
handler.broadcastReconnectNotification()
),
))
}

View File

@@ -0,0 +1,46 @@
import { applyWSSHandler } from "@trpc/server/adapters/ws"
import { Context, Effect, Layer } from "effect"
import ws from "ws"
import { ExpressHTTPServer } from "../http/ExpressHTTPServer"
import { ServerConfig } from "../ServerConfig"
import { TRPCContextCreator } from "../trpc/TRPCContextCreator"
import { RPCRouter } from "./RPCRouter"
export class RPCWebSocketServer extends Context.Tag("RPCWebSocketServer")<RPCWebSocketServer, {
wss: ws.Server
handler: ReturnType<typeof applyWSSHandler>
}>() {}
export module RPCWebSocketServer {
export const Live = Layer.effect(RPCWebSocketServer, ServerConfig.rpcHTTPRoot.pipe(
Effect.flatMap(rpcHTTPRoot => Effect.acquireRelease(
Effect.gen(function*() {
yield* Effect.logInfo(`WebSocket server started on ${ rpcHTTPRoot }`)
const wss = new ws.WebSocketServer({
server: yield* ExpressHTTPServer,
host: rpcHTTPRoot,
})
return {
wss,
handler: applyWSSHandler({
wss,
router: yield* RPCRouter,
createContext: (yield* TRPCContextCreator).createWebSocketContext,
}),
}
}),
({ wss, handler }) => Effect.gen(function*() {
yield* Effect.logInfo(`WebSocket server on ${ rpcHTTPRoot } is stopping. Waiting for existing connections to end...`)
handler.broadcastReconnectNotification()
yield* Effect.async(resume => {
wss.close(() => resume(Effect.logInfo("WebSocket server closed")))
})
}),
))
))
}

View File

@@ -0,0 +1,54 @@
import { Schema as S } from "@effect/schema"
import { ServerTime } from "@todo-tests/common/data"
import { TRPCError } from "@trpc/server"
import { observable } from "@trpc/server/observable"
import { Data, Effect, Fiber, Schedule } from "effect"
import { TRPCBuilder } from "../../trpc/TRPCBuilder"
import { RPCProcedureBuilder } from "../procedures/RPCProcedureBuilder"
import { todoRouter } from "./todo"
const encodeServerTime = S.encode(ServerTime)
export const indexRouter = Effect.gen(function*() {
const t = yield* TRPCBuilder
const procedure = yield* RPCProcedureBuilder
return t.router({
ping: procedure.query(({ ctx }) => ctx.run(
Effect.succeed("pong")
)),
serverTime: procedure
.subscription(({ ctx }) =>
observable<typeof ServerTime.Encoded>(emit => {
const emitter = ctx.fork(
Effect.sync(() => new Date()).pipe(
Effect.flatMap(encodeServerTime),
Effect.map(emit.next),
Effect.repeat(Schedule.fixed("1 second")),
)
)
return () => ctx.fork(
Fiber.interrupt(emitter)
)
})
),
fail1: procedure.query(({ ctx }) => ctx.run(
Effect.fail(new AnError({ aValue: "A value" }))
)),
fail2: procedure.query(({ ctx }) => ctx.run(
Effect.fail(new TRPCError({ code: "NOT_IMPLEMENTED" }))
)),
todo: yield* todoRouter,
})
})
class AnError extends Data.TaggedError("AnError")<{
aValue: string
}> {}

View File

@@ -9,9 +9,10 @@ import { RPCProcedureBuilder } from "../procedures/RPCProcedureBuilder"
const encodeTodos = S.encode(S.Chunk(JsonifiableTodo)) const encodeTodos = S.encode(S.Chunk(JsonifiableTodo))
const encodeOptionalTodo = S.encode(S.OptionFromNullOr(JsonifiableTodo)) const encodeOptionalTodo = S.encode(S.OptionFromNullOr(JsonifiableTodo))
const decodeTodo = S.decode(JsonifiableTodo)
export const todosRouter = Effect.gen(function*() { export const todoRouter = Effect.gen(function*() {
const t = yield* TRPCBuilder const t = yield* TRPCBuilder
const procedure = yield* RPCProcedureBuilder const procedure = yield* RPCProcedureBuilder
@@ -19,7 +20,10 @@ export const todosRouter = Effect.gen(function*() {
all: procedure all: procedure
.query(({ ctx }) => ctx.run(Effect.gen(function*() { .query(({ ctx }) => ctx.run(Effect.gen(function*() {
const todos = yield* TodoRepository const todos = yield* TodoRepository
return yield* encodeTodos(yield* todos.todos.get)
return yield* encodeTodos(
yield* todos.todos.get
)
}))), }))),
changes: procedure changes: procedure
@@ -51,27 +55,47 @@ export const todosRouter = Effect.gen(function*() {
getByID: procedure getByID: procedure
.input(S.decodeUnknownPromise(S.String)) .input(S.decodeUnknownPromise(S.String))
.query(({ ctx, input }) => ctx.run(Effect.gen(function*() { .query(({ ctx, input }) => ctx.run(Effect.gen(function*() {
const todos = yield* TodoRepository
return yield* encodeOptionalTodo( return yield* encodeOptionalTodo(
yield* (yield* TodoRepository).getByID(input) yield* todos.getByID(input)
) )
}))), }))),
add: procedure add: procedure
.input(S.decodeUnknownPromise(JsonifiableTodo)) .input(S.decodeUnknownPromise(
S.encodedSchema(JsonifiableTodo)
))
.mutation(({ ctx, input }) => ctx.run(Effect.gen(function*() { .mutation(({ ctx, input }) => ctx.run(Effect.gen(function*() {
return yield* (yield* TodoRepository).add(input) const todos = yield* TodoRepository
return yield* todos.add(
yield* decodeTodo(input)
)
}))), }))),
update: procedure update: procedure
.input(S.decodeUnknownPromise(JsonifiableTodo)) .input(S.decodeUnknownPromise(
S.encodedSchema(JsonifiableTodo)
))
.mutation(({ ctx, input }) => ctx.run(Effect.gen(function*() { .mutation(({ ctx, input }) => ctx.run(Effect.gen(function*() {
return yield* (yield* TodoRepository).update(input) const todos = yield* TodoRepository
return yield* todos.update(
yield* decodeTodo(input)
)
}))), }))),
remove: procedure remove: procedure
.input(S.decodeUnknownPromise(JsonifiableTodo)) .input(S.decodeUnknownPromise(
S.encodedSchema(JsonifiableTodo)
))
.mutation(({ ctx, input }) => ctx.run(Effect.gen(function*() { .mutation(({ ctx, input }) => ctx.run(Effect.gen(function*() {
return yield* (yield* TodoRepository).remove(input) const todos = yield* TodoRepository
return yield* todos.remove(
yield* decodeTodo(input)
)
}))), }))),
}) })
}) })

View File

@@ -37,7 +37,7 @@ export class TodoRepositoryService {
add(todo: Todo) { add(todo: Todo) {
return Effect.gen(this, function*() { return Effect.gen(this, function*() {
if (Option.isSome(todo.id)) if (Option.isSome(todo.id))
return yield* Effect.fail(new TodoHasID({ todo })) return yield* new TodoHasID({ todo })
const id: string = crypto.randomUUID() const id: string = crypto.randomUUID()

View File

@@ -1,6 +1,9 @@
import type { TRPCError } from "@trpc/server" import type { TRPCError } from "@trpc/server"
import type { Effect, Runtime } from "effect" import { Data, type Effect, type Runtime } from "effect"
import type { RuntimeFiber } from "effect/Fiber" import type { RuntimeFiber } from "effect/Fiber"
import express from "express"
import type { IncomingMessage } from "node:http"
import { WebSocket } from "ws"
import type { Services } from "../Services" import type { Services } from "../Services"
@@ -17,5 +20,20 @@ export interface TRPCContext {
options?: Runtime.RunForkOptions, options?: Runtime.RunForkOptions,
) => RuntimeFiber<A, TRPCError> ) => RuntimeFiber<A, TRPCError>
// req: Request transaction: TRPCContextTransaction
} }
export type TRPCContextTransaction = Data.TaggedEnum<{
Express: {
readonly req: express.Request
readonly res: express.Response
}
WebSocket: {
readonly req: IncomingMessage
readonly res: WebSocket
}
}>
export const TRPCContextTransactionEnum = Data.taggedEnum<TRPCContextTransaction>()

View File

@@ -1,20 +1,19 @@
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server"
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express" import type { CreateExpressContextOptions } from "@trpc/server/adapters/express"
import type { CreateWSSContextFnOptions } from "@trpc/server/adapters/ws" import type { CreateWSSContextFnOptions } from "@trpc/server/adapters/ws"
import { Context, Effect, Layer, Runtime } from "effect" import { Cause, Context, Effect, Layer, Runtime } from "effect"
import type { Services } from "../Services" import type { Services } from "../Services"
import type { TRPCContext } from "./TRPCContext" import { TRPCContextTransactionEnum, type TRPCContext, type TRPCContextTransaction } from "./TRPCContext"
/** /**
* Provides a function that instantiates a fresh context for each tRPC procedure call * Provides functions that instantiate a fresh context for each tRPC procedure call
*/ */
export class TRPCContextCreator extends Context.Tag("TRPCContextCreator")<TRPCContextCreator, export class TRPCContextCreator extends Context.Tag("TRPCContextCreator")<TRPCContextCreator, {
(opts: createContext: (transaction: TRPCContextTransaction) => TRPCContext
| CreateExpressContextOptions createExpressContext: (context: CreateExpressContextOptions) => TRPCContext
| CreateWSSContextFnOptions createWebSocketContext: (context: CreateWSSContextFnOptions) => TRPCContext
) => TRPCContext }>() {}
>() {}
export module TRPCContextCreator { export module TRPCContextCreator {
export const Live = Layer.effect(TRPCContextCreator, Effect.gen(function*() { export const Live = Layer.effect(TRPCContextCreator, Effect.gen(function*() {
@@ -24,7 +23,7 @@ export module TRPCContextCreator {
effect: Effect.Effect<A, E, Services>, effect: Effect.Effect<A, E, Services>,
options?: { readonly signal?: AbortSignal }, options?: { readonly signal?: AbortSignal },
) => Runtime.runPromise(runtime)( ) => Runtime.runPromise(runtime)(
effect.pipe(mapErrorsToTRPC), effect.pipe(mapErrors),
options, options,
) )
@@ -32,40 +31,82 @@ export module TRPCContextCreator {
effect: Effect.Effect<A, E, Services>, effect: Effect.Effect<A, E, Services>,
options?: Runtime.RunForkOptions, options?: Runtime.RunForkOptions,
) => Runtime.runFork(runtime)( ) => Runtime.runFork(runtime)(
effect.pipe(mapErrorsToTRPC), effect.pipe(mapErrors),
options, options,
) )
return ({ req }) => ({
const createContext = (transaction: TRPCContextTransaction) => ({
runtime, runtime,
run, run,
fork, fork,
// req, transaction,
}) })
return {
createContext,
createExpressContext: context => createContext(TRPCContextTransactionEnum.Express(context)),
createWebSocketContext: context => createContext(TRPCContextTransactionEnum.WebSocket(context)),
}
})) }))
} }
const mapErrorsToTRPC = <A, E, R>(effect: Effect.Effect<A, E, R>) => const mapErrors = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.sandbox(effect).pipe( Effect.sandbox(effect).pipe(
Effect.catchTags({ Effect.catchTags({
Die: cause => Effect.fail(
new TRPCError({ code: "INTERNAL_SERVER_ERROR", cause })
),
Interrupt: cause => Effect.fail(
new TRPCError({ code: "INTERNAL_SERVER_ERROR", cause })
),
Fail: cause => Effect.fail(
new TRPCError({ code: "INTERNAL_SERVER_ERROR", cause })
),
Empty: cause => Effect.fail( Empty: cause => Effect.fail(
new TRPCError({ code: "INTERNAL_SERVER_ERROR", cause }) new TRPCError({
), code: "INTERNAL_SERVER_ERROR",
Parallel: cause => Effect.fail( cause: new TRPCErrorCause(cause),
new TRPCError({ code: "INTERNAL_SERVER_ERROR", cause })
),
Sequential: cause => Effect.fail(
new TRPCError({ code: "INTERNAL_SERVER_ERROR", cause })
),
}) })
),
Fail: cause => Effect.fail(
cause.error instanceof TRPCError
? cause.error
: new TRPCError({
code: "INTERNAL_SERVER_ERROR",
cause: new TRPCErrorCause(cause),
})
),
Die: cause => Effect.fail(
cause.defect instanceof TRPCError
? cause.defect
: new TRPCError({
code: "INTERNAL_SERVER_ERROR",
cause: new TRPCErrorCause(cause),
})
),
Interrupt: cause => Effect.fail(
new TRPCError({
code: "INTERNAL_SERVER_ERROR",
cause: new TRPCErrorCause(cause),
})
),
Sequential: cause => Effect.fail(
new TRPCError({
code: "INTERNAL_SERVER_ERROR",
cause: new TRPCErrorCause(cause),
})
),
Parallel: cause => Effect.fail(
new TRPCError({
code: "INTERNAL_SERVER_ERROR",
cause: new TRPCErrorCause(cause),
})
),
}),
Effect.tapError(Effect.logError),
) )
export class TRPCErrorCause<E> extends Error {
constructor(readonly cause: Cause.Cause<E>) {
super()
}
}

View File

@@ -14,5 +14,6 @@ module.exports = {
'warn', 'warn',
{ allowConstantExport: true }, { allowConstantExport: true },
], ],
"@typescript-eslint/no-namespace": "off",
}, },
} }

View File

@@ -9,34 +9,39 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@effect/schema": "^0.68.17", "@effect/schema": "^0.68.26",
"@radix-ui/themes": "^3.1.1",
"@tanstack/react-query": "4", "@tanstack/react-query": "4",
"@tanstack/react-router": "^1.44.2", "@tanstack/react-router": "^1.45.6",
"@thilawyn/thilalib": "^0.1.5", "@thilawyn/thilalib": "^0.1.5",
"@todo-tests/common": "workspace:*", "@todo-tests/common": "workspace:*",
"@trpc/client": "^10.45.2", "@trpc/client": "^10.45.2",
"@trpc/react-query": "^10.45.2", "@trpc/react-query": "^10.45.2",
"effect": "^3.4.7", "effect": "^3.5.6",
"mobx": "^6.13.0", "framer-motion": "^11.3.8",
"lodash-es": "^4.17.21",
"lucide-react": "^0.412.0",
"mobx": "^6.13.1",
"mobx-react-lite": "^4.0.7", "mobx-react-lite": "^4.0.7",
"primereact": "^10.7.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"remeda": "^2.6.0"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/router-devtools": "^1.44.2", "@tanstack/router-devtools": "^1.45.6",
"@tanstack/router-plugin": "^1.44.3", "@tanstack/router-plugin": "^1.45.3",
"@types/lodash-es": "^4.17.12",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.15.0", "@typescript-eslint/eslint-plugin": "^7.16.1",
"@typescript-eslint/parser": "^7.15.0", "@typescript-eslint/parser": "^7.16.1",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7", "eslint-plugin-react-refresh": "^0.4.8",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.6",
"vite": "^5.3.3" "vite": "^5.3.4"
} }
} }

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,34 @@
import { getRouterContext, Outlet } from "@tanstack/react-router"
import { motion, useIsPresent, type HTMLMotionProps } from "framer-motion"
import { cloneDeep } from "lodash-es"
import { forwardRef, useContext, useRef } from "react"
export module AnimatedOutlet {
export interface Props extends HTMLMotionProps<"div"> {}
}
export const AnimatedOutlet = forwardRef<HTMLDivElement, AnimatedOutlet.Props>((props, ref) => {
const RouterContext = getRouterContext()
const routerContext = useContext(RouterContext)
const renderedContext = useRef(routerContext)
const isPresent = useIsPresent()
if (isPresent)
renderedContext.current = cloneDeep(routerContext)
return (
<motion.div
ref={ref}
{...props}
>
<RouterContext.Provider value={renderedContext.current}>
<Outlet />
</RouterContext.Provider>
</motion.div>
)
})

View File

@@ -0,0 +1,13 @@
import { Schema as S } from "@effect/schema"
import { MobXObservable, MutableClass } from "@thilawyn/thilalib/effect/schema/class"
import { JsonifiableTodo as CommonJsonifiableTodo, Todo as CommonTodo } from "@todo-tests/common/data"
export class Todo
extends MutableClass<Todo>("Todo")(CommonTodo.fields).pipe(
MobXObservable
)
{}
export const JsonifiableTodo = CommonJsonifiableTodo.pipe(S.compose(Todo))

View File

@@ -1,9 +0,0 @@
import { MobXObservable, MutableClass } from "@thilawyn/thilalib/effect/schema/class"
import { Todo as CommonTodo } from "@todo-tests/common/data"
export class Todo
extends MutableClass<Todo>("Todo")(CommonTodo.fields).pipe(
MobXObservable
)
{}

View File

@@ -1 +1 @@
export * from "./Todo" export * from "./Todo.class"

View File

@@ -1,12 +1,13 @@
import { Theme } from "@radix-ui/themes"
import { RouterProvider, createRouter } from "@tanstack/react-router" import { RouterProvider, createRouter } from "@tanstack/react-router"
import { PrimeReactProvider } from "primereact/api"
import React from "react" import React from "react"
import ReactDOM from "react-dom/client" import ReactDOM from "react-dom/client"
import { routeTree } from "./routeTree.gen" import { routeTree } from "./routeTree.gen"
import { TRPCClientProvider } from "./trpc/TRPCClientProvider" import { TRPCClientProvider } from "./trpc/TRPCClientProvider"
import "primereact/resources/themes/lara-light-cyan/theme.css"
import "./index.css" import "@radix-ui/themes/styles.css"
import "./tailwind.css"
const router = createRouter({ routeTree }) const router = createRouter({ routeTree })
@@ -17,13 +18,12 @@ declare module "@tanstack/react-router" {
} }
} }
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<PrimeReactProvider> <Theme appearance="dark">
<TRPCClientProvider> <TRPCClientProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
</TRPCClientProvider> </TRPCClientProvider>
</PrimeReactProvider> </Theme>
</React.StrictMode> </React.StrictMode>
) )

View File

@@ -16,10 +16,16 @@ import { Route as rootRoute } from './routes/__root'
// Create Virtual Routes // Create Virtual Routes
const AboutLazyImport = createFileRoute('/about')()
const IndexLazyImport = createFileRoute('/')() const IndexLazyImport = createFileRoute('/')()
// Create/Update Routes // Create/Update Routes
const AboutLazyRoute = AboutLazyImport.update({
path: '/about',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/about.lazy').then((d) => d.Route))
const IndexLazyRoute = IndexLazyImport.update({ const IndexLazyRoute = IndexLazyImport.update({
path: '/', path: '/',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
@@ -36,12 +42,22 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexLazyImport preLoaderRoute: typeof IndexLazyImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/about': {
id: '/about'
path: '/about'
fullPath: '/about'
preLoaderRoute: typeof AboutLazyImport
parentRoute: typeof rootRoute
}
} }
} }
// Create and export the route tree // Create and export the route tree
export const routeTree = rootRoute.addChildren({ IndexLazyRoute }) export const routeTree = rootRoute.addChildren({
IndexLazyRoute,
AboutLazyRoute,
})
/* prettier-ignore-end */ /* prettier-ignore-end */
@@ -51,11 +67,15 @@ export const routeTree = rootRoute.addChildren({ IndexLazyRoute })
"__root__": { "__root__": {
"filePath": "__root.tsx", "filePath": "__root.tsx",
"children": [ "children": [
"/" "/",
"/about"
] ]
}, },
"/": { "/": {
"filePath": "index.lazy.tsx" "filePath": "index.lazy.tsx"
},
"/about": {
"filePath": "about.lazy.tsx"
} }
} }
} }

View File

@@ -1,5 +1,8 @@
import { Outlet, createRootRoute } from "@tanstack/react-router" import { Button, Container, Flex } from "@radix-ui/themes"
import { createRootRoute, useMatch, useMatches, useNavigate } from "@tanstack/react-router"
import { AnimatePresence } from "framer-motion"
import { Suspense, lazy } from "react" import { Suspense, lazy } from "react"
import { AnimatedOutlet } from "../AnimatedOutlet"
const TanStackRouterDevtools = process.env.NODE_ENV === "production" const TanStackRouterDevtools = process.env.NODE_ENV === "production"
@@ -8,15 +11,63 @@ const TanStackRouterDevtools = process.env.NODE_ENV === "production"
default: res.TanStackRouterDevtools default: res.TanStackRouterDevtools
}))) })))
const ThemePanel = process.env.NODE_ENV === "production"
? () => null
: lazy(() => import("@radix-ui/themes").then(res => ({
default: res.ThemePanel
})))
export function Root() { export function Root() {
return <>
<div className="container mx-auto mt-8">
<Outlet />
</div>
<Suspense><TanStackRouterDevtools /></Suspense> const matches = useMatches()
const match = useMatch({ strict: false })
const nextMatch = matches[ matches.findIndex(d => d.id === match.id) + 1 ]
const navigate = useNavigate()
return <>
<Container pt="8" pb="4">
<Flex
gap="4"
justify="center"
align="center"
>
<Button
variant="soft"
onClick={() => navigate({ to: "/" })}
>
Home
</Button>
<Button
variant="soft"
onClick={() => navigate({ to: "/about" })}
>
About
</Button>
</Flex>
</Container>
<AnimatePresence mode="popLayout">
<AnimatedOutlet
key={nextMatch.id}
transition={{ x: { type: "spring", stiffness: 300, damping: 30 } }}
initial={{ x: "100vw", opacity: 0 }}
animate={{ x: "0", opacity: 1 }}
exit={{ x: "-100vw", opacity: 0 }}
/>
</AnimatePresence>
<Suspense>
<TanStackRouterDevtools />
<ThemePanel />
</Suspense>
</> </>
} }
export const Route = createRootRoute({ component: Root }) export const Route = createRootRoute({ component: Root })

View File

@@ -0,0 +1,16 @@
import { Container, Heading } from "@radix-ui/themes"
import { createLazyFileRoute } from "@tanstack/react-router"
import { observer } from "mobx-react-lite"
export const About = observer(() => {
return (
<Container>
<Heading align="center">About</Heading>
</Container>
)
})
export const Route = createLazyFileRoute("/about")({ component: About })

View File

@@ -1,38 +1,51 @@
import { Schema as S } from "@effect/schema" import { Schema as S } from "@effect/schema"
import { Container, Flex, Text } from "@radix-ui/themes"
import { createLazyFileRoute } from "@tanstack/react-router" import { createLazyFileRoute } from "@tanstack/react-router"
import { JsonifiableTodo } from "@todo-tests/common/data" import { ServerTime } from "@todo-tests/common/data"
import { Option, flow, identity } from "effect" import { Option, flow, identity } from "effect"
import { IObservableArray, observable } from "mobx" import { type IObservableArray, observable } from "mobx"
import { observer } from "mobx-react-lite" import { observer } from "mobx-react-lite"
import { useState } from "react" import { useState } from "react"
import { Todo } from "../data" import { JsonifiableTodo, Todo } from "../data"
import { VTodo } from "../todo/VTodo" import { VTodo } from "../todo/VTodo"
import { trpc } from "../trpc/trpc" import { trpc } from "../trpc/trpc"
const decodeServerTime = S.decodeSync(ServerTime)
const decodeTodos = flow( const decodeTodos = flow(
S.decodeSync( S.decodeSync(
S.mutable(S.Array( S.mutable(S.Array(JsonifiableTodo))
JsonifiableTodo.pipe(S.compose(Todo))
))
), ),
observable.array, observable.array,
) )
export const Index = observer(() => { export const Index = observer(() => {
const [todos, setTodos] = useState<IObservableArray<Todo>>(observable.array()) const [serverTime, setServerTime] = useState(new Date())
trpc.todos.changes.useSubscription(undefined, { trpc.serverTime.useSubscription(undefined, {
onData: data => { onData: flow(decodeServerTime, setServerTime)
setTodos(decodeTodos(data))
}
}) })
const [todos, setTodos] = useState<IObservableArray<Todo>>(observable.array())
trpc.todo.changes.useSubscription(undefined, {
onData: flow(decodeTodos, setTodos)
})
return ( return (
<div className="flex flex-col gap-1 items-stretch"> <Container>
<Flex
direction="column"
align="stretch"
gap="1"
>
<Text align="center">{serverTime.toString()}</Text>
{todos.map(todo => ( {todos.map(todo => (
<VTodo <VTodo
key={Option.match(todo.id, { key={Option.match(todo.id, {
@@ -43,7 +56,8 @@ export const Index = observer(() => {
todo={todo} todo={todo}
/> />
))} ))}
</div> </Flex>
</Container>
) )
}) })

View File

@@ -1,19 +1,83 @@
import { Schema as S } from "@effect/schema"
import { Card, Flex, IconButton, Skeleton, Text, Tooltip } from "@radix-ui/themes"
import { ChevronDown, ChevronUp, X } from "lucide-react"
import { observer } from "mobx-react-lite" import { observer } from "mobx-react-lite"
import { Card } from "primereact/card" import { JsonifiableTodo, Todo } from "../data"
import { Todo } from "../data" import { trpc } from "../trpc/trpc"
export interface VTodoProps { const encodeTodo = S.encodeSync(JsonifiableTodo)
export module VTodo {
export interface Props {
todo: Todo todo: Todo
}
} }
export const VTodo = observer(({ todo }: VTodoProps) => { export const VTodo = observer(({ todo }: VTodo.Props) => {
const updateTodo = trpc.todo.update.useMutation()
const removeTodo = trpc.todo.remove.useMutation()
return ( return (
<Skeleton loading={updateTodo.isLoading || removeTodo.isLoading}>
<Card> <Card>
<p>{todo.content}</p> <Flex
justify="between"
align="center"
>
<Text>{todo.content}</Text>
<Flex
align="center"
gap="1"
>
<Tooltip content="Move down">
<IconButton
variant="outline"
onClick={() => updateTodo.mutate(encodeTodo(
new Todo({
...todo,
order: todo.order + 2,
}, { disableValidation: true })
))}
>
<ChevronDown />
</IconButton>
</Tooltip>
<Tooltip content="Move up">
<IconButton
variant="outline"
onClick={() => updateTodo.mutate(encodeTodo(
new Todo({
...todo,
order: todo.order - 2,
}, { disableValidation: true })
))}
>
<ChevronUp />
</IconButton>
</Tooltip>
<Tooltip content="Delete">
<IconButton
variant="outline"
onClick={() => removeTodo.mutate(encodeTodo(todo))}
>
<X />
</IconButton>
</Tooltip>
</Flex>
</Flex>
</Card> </Card>
</Skeleton>
) )
}) })

View File

@@ -1,29 +1,35 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { createWSClient, wsLink } from "@trpc/client" import { createWSClient, httpBatchLink, splitLink, wsLink } from "@trpc/client"
import { ReactNode, useState } from "react" import { type ReactNode, useMemo, useState } from "react"
import { trpc } from "./trpc" import { trpc } from "./trpc"
export interface TRPCClientProviderProps { export module TRPCClientProvider {
export interface Props {
children?: ReactNode children?: ReactNode
}
} }
export function TRPCClientProvider({ children }: TRPCClientProviderProps) { export function TRPCClientProvider({ children }: TRPCClientProvider.Props) {
const [queryClient] = useState(new QueryClient()) const [queryClient] = useState(new QueryClient())
const [wsClient] = useState(createWSClient({ url: "ws://localhost:8080" })) const [wsClient] = useState(createWSClient({ url: "/rpc" }))
const [trpcClient] = useState(trpc.createClient({ const trpcClient = useMemo(() => trpc.createClient({
links: [ links: [
// httpBatchLink({ splitLink({
// url: "http://localhost:8080/rpc", condition: op => op.type === "subscription",
// headers: async () => ({}),
// }),
wsLink({ client: wsClient }), true: wsLink({ client: wsClient }),
false: httpBatchLink({
url: "/rpc",
headers: {},
}),
})
] ]
})) }), [wsClient])
return ( return (

View File

@@ -11,6 +11,7 @@
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"moduleDetection": "force", "moduleDetection": "force",

View File

@@ -5,8 +5,13 @@ import { defineConfig } from "vite"
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
server: {
host: true,
port: 80,
},
plugins: [ plugins: [
TanStackRouterVite(), TanStackRouterVite(),
react(), react(),
] ],
}) })