Adds WebSocket support (#1203)

This commit is contained in:
Shayne Czyzewski 2023-06-19 04:49:57 -04:00 committed by GitHub
parent 077309356b
commit 3e85b0f067
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
81 changed files with 1404 additions and 332 deletions

View File

@ -12,6 +12,9 @@
### Public folder support
Wasp now supports a `public` folder in the `client` folder. This folder will be copied to the `public` folder in the build folder. This is useful for adding static assets to your project, like favicons, robots.txt, etc.
### Type safe WebSocket support
Wasp now supports WebSockets! This will allow you to have a persistent, realtime connection between your client and server, which is great for chat apps, games, and more. What's more, Wasp's WebSockets support full-stack type safety, so you can be sure that your client and server are communicating with the correct types.
## v0.10.6
### Bug fixes

View File

@ -1,6 +1,8 @@
import axios, { type AxiosError } from 'axios'
import config from './config'
import { storage } from './storage'
import { apiEventsEmitter } from './api/events'
const api = axios.create({
baseURL: config.apiUrl,
@ -10,20 +12,26 @@ const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
export function setAuthToken (token: string): void {
export function setAuthToken(token: string): void {
authToken = token
storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
apiEventsEmitter.emit('authToken.set')
}
export function getAuthToken(): string | undefined {
return authToken
}
export function clearAuthToken(): void {
authToken = undefined
storage.remove(WASP_APP_AUTH_TOKEN_NAME)
apiEventsEmitter.emit('authToken.clear')
}
export function removeLocalUserData(): void {
authToken = undefined
storage.clear()
apiEventsEmitter.emit('authToken.clear')
}
api.interceptors.request.use((request) => {
@ -40,6 +48,23 @@ api.interceptors.response.use(undefined, (error) => {
return Promise.reject(error)
})
// This handler will run on other tabs (not the active one calling API functions),
// and will ensure they know about auth token changes.
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
// "Note: This won't work on the same page that is making the changes — it is really a way
// for other pages on the domain using the storage to sync any changes that are made."
window.addEventListener('storage', (event) => {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) {
if (!!event.newValue) {
authToken = event.newValue
apiEventsEmitter.emit('authToken.set')
} else {
authToken = undefined
apiEventsEmitter.emit('authToken.clear')
}
}
})
/**
* Takes an error returned by the app's API (as returned by axios), and transforms into a more
* standard format to be further used by the client. It is also assumed that given API

View File

@ -0,0 +1,11 @@
import mitt, { Emitter } from 'mitt';
type ApiEvents = {
// key: Event name
// type: Event payload type
'authToken.set': void;
'authToken.clear': void;
};
// Used to allow API clients to register for auth token change events.
export const apiEventsEmitter: Emitter<ApiEvents> = mitt<ApiEvents>();

View File

@ -13,6 +13,10 @@ import {
{=& setupFn.importStatement =}
{=/ setupFn.isDefined =}
{=# areWebSocketsUsed =}
import { WebSocketProvider } from './webSocket/WebSocketProvider'
{=/ areWebSocketsUsed =}
startApp()
async function startApp() {
@ -29,7 +33,14 @@ async function render() {
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
{=# areWebSocketsUsed =}
<WebSocketProvider>
{router}
</WebSocketProvider>
{=/ areWebSocketsUsed =}
{=^ areWebSocketsUsed =}
{router}
{=/ areWebSocketsUsed =}
</QueryClientProvider>
</React.StrictMode>
)

View File

@ -1,44 +1,50 @@
export type DataStore = {
set(key: string, value: unknown): void;
get(key: string): unknown;
remove(key: string): void;
clear(): void;
};
getPrefixedKey(key: string): string
set(key: string, value: unknown): void
get(key: string): unknown
remove(key: string): void
clear(): void
}
function createLocalStorageDataStore(prefix: string): DataStore {
return {
set(key, value) {
ensureLocalStorageIsAvailable();
localStorage.setItem(`${prefix}:${key}`, JSON.stringify(value));
},
get(key) {
ensureLocalStorageIsAvailable();
const value = localStorage.getItem(`${prefix}:${key}`);
try {
return value ? JSON.parse(value) : undefined;
} catch (e: any) {
return undefined;
}
},
remove(key) {
ensureLocalStorageIsAvailable();
localStorage.removeItem(`${prefix}:${key}`);
},
clear() {
ensureLocalStorageIsAvailable();
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(prefix)) {
localStorage.removeItem(key);
}
});
},
};
function getPrefixedKey(key: string): string {
return `${prefix}:${key}`
}
return {
getPrefixedKey,
set(key, value) {
ensureLocalStorageIsAvailable()
localStorage.setItem(getPrefixedKey(key), JSON.stringify(value))
},
get(key) {
ensureLocalStorageIsAvailable()
const value = localStorage.getItem(getPrefixedKey(key))
try {
return value ? JSON.parse(value) : undefined
} catch (e: any) {
return undefined
}
},
remove(key) {
ensureLocalStorageIsAvailable()
localStorage.removeItem(getPrefixedKey(key))
},
clear() {
ensureLocalStorageIsAvailable()
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(prefix)) {
localStorage.removeItem(key)
}
})
},
}
}
export const storage = createLocalStorageDataStore('wasp');
export const storage = createLocalStorageDataStore('wasp')
function ensureLocalStorageIsAvailable(): void {
if (!window.localStorage) {
throw new Error('Local storage is not available.');
}
if (!window.localStorage) {
throw new Error('Local storage is not available.')
}
}

View File

@ -0,0 +1,36 @@
import { useContext, useEffect } from 'react'
import { WebSocketContext } from './webSocket/WebSocketProvider'
import type {
ClientToServerEvents,
ServerToClientEvents,
} from '../../server/src/webSocket'
export type {
ClientToServerEvents,
ServerToClientEvents,
} from '../../server/src/webSocket'
export type ServerToClientPayload<Event extends keyof ServerToClientEvents> =
Parameters<ServerToClientEvents[Event]>[0]
export type ClientToServerPayload<Event extends keyof ClientToServerEvents> =
Parameters<ClientToServerEvents[Event]>[0]
export function useSocket() {
return useContext(WebSocketContext)
}
export function useSocketListener<Event extends keyof ServerToClientEvents>(
event: Event,
handler: ServerToClientEvents[Event]
) {
const { socket } = useContext(WebSocketContext)
useEffect(() => {
// Casting to `keyof ServerToClientEvents` is necessary because TypeScript
// reports the handler function as incompatible with the event type.
// See https://github.com/wasp-lang/wasp/pull/1203#discussion_r1232068898
socket.on(event as keyof ServerToClientEvents, handler)
return () => {
socket.off(event as keyof ServerToClientEvents, handler)
}
}, [event, handler])
}

View File

@ -0,0 +1,63 @@
{{={= =}=}}
import { createContext, useState, useEffect } from 'react'
import { io, Socket } from 'socket.io-client'
import { getAuthToken } from '../api'
import { apiEventsEmitter } from '../api/events'
import config from '../config'
import type { ClientToServerEvents, ServerToClientEvents } from '../webSocket';
// TODO: In the future, it would be nice if users could pass more
// options to `io`, likely via some `configFn`.
export const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io(config.apiUrl, { autoConnect: {= autoConnect =} })
function refreshAuthToken() {
// NOTE: When we figure out how `auth: true` works for Operations, we should
// mirror that behavior here for WebSockets. Ref: https://github.com/wasp-lang/wasp/issues/1133
socket.auth = {
token: getAuthToken()
}
if (socket.connected) {
socket.disconnect()
socket.connect()
}
}
refreshAuthToken()
apiEventsEmitter.on('authToken.set', refreshAuthToken)
apiEventsEmitter.on('authToken.clear', refreshAuthToken)
export const WebSocketContext = createContext({
socket,
isConnected: false,
});
export function WebSocketProvider({ children }: { children: JSX.Element }) {
const [isConnected, setIsConnected] = useState(socket.connected)
useEffect(() => {
function onConnect() {
setIsConnected(true)
}
function onDisconnect() {
setIsConnected(false)
}
socket.on('connect', onConnect)
socket.on('disconnect', onDisconnect)
return () => {
socket.off('connect', onConnect)
socket.off('disconnect', onDisconnect)
}
}, [])
return (
<WebSocketContext.Provider value={{ socket, isConnected }}>
{children}
</WebSocketContext.Provider>
);
}

View File

@ -28,30 +28,7 @@ const auth = handleRejection(async (req, res, next) => {
if (authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7, authHeader.length)
let userIdFromToken
try {
userIdFromToken = (await verify(token)).id
} catch (error) {
if (['TokenExpiredError', 'JsonWebTokenError', 'NotBeforeError'].includes(error.name)) {
throwInvalidCredentialsError()
} else {
throw error
}
}
const user = await prisma.{= userEntityLower =}.findUnique({ where: { id: userIdFromToken } })
if (!user) {
throwInvalidCredentialsError()
}
// TODO: This logic must match the type in types/index.ts (if we remove the
// password field from the object here, we must to do the same there).
// Ideally, these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
const { password, ...userView } = user
req.user = userView
req.user = await getUserFromToken(token)
} else {
throwInvalidCredentialsError()
}
@ -59,6 +36,32 @@ const auth = handleRejection(async (req, res, next) => {
next()
})
export async function getUserFromToken(token) {
let userIdFromToken
try {
userIdFromToken = (await verify(token)).id
} catch (error) {
if (['TokenExpiredError', 'JsonWebTokenError', 'NotBeforeError'].includes(error.name)) {
throwInvalidCredentialsError()
} else {
throw error
}
}
const user = await prisma.{= userEntityLower =}.findUnique({ where: { id: userIdFromToken } })
if (!user) {
throwInvalidCredentialsError()
}
// TODO: This logic must match the type in types/index.ts (if we remove the
// password field from the object here, we must to do the same there).
// Ideally, these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
const { password, ...userView } = user
return userView
}
const SP = new SecurePassword()
export const hashPassword = async (password) => {

View File

@ -14,6 +14,10 @@ import { startPgBoss } from './jobs/core/pgBoss/pgBoss.js'
import './jobs/core/allJobs.js'
{=/ isPgBossJobExecutorUsed =}
{=# userWebSocketFn.isDefined =}
import { init as initWebSocket } from './webSocket/initialization.js'
{=/ userWebSocketFn.isDefined =}
const startServer = async () => {
{=# isPgBossJobExecutorUsed =}
await startPgBoss()
@ -29,6 +33,10 @@ const startServer = async () => {
await ({= setupFn.importIdentifier =} as ServerSetupFn)(serverSetupFnContext)
{=/ setupFn.isDefined =}
{=# userWebSocketFn.isDefined =}
await initWebSocket(server)
{=/ userWebSocketFn.isDefined =}
server.listen(port)
server.on('error', (error: NodeJS.ErrnoException) => {

View File

@ -0,0 +1,47 @@
{{={= =}=}}
import { Server } from 'socket.io'
import { EventsMap, DefaultEventsMap } from '@socket.io/component-emitter'
import prisma from '../dbClient.js'
{=& userWebSocketFn.importStatement =}
export type WebSocketDefinition<
ClientToServerEvents extends EventsMap = DefaultEventsMap,
ServerToClientEvents extends EventsMap = DefaultEventsMap,
InterServerEvents extends EventsMap = DefaultEventsMap,
SocketData extends WaspSocketData = WaspSocketData
> = (
io: Server<
ClientToServerEvents,
ServerToClientEvents,
InterServerEvents,
SocketData
>,
context: {
entities: {
{=# allEntities =}
{= name =}: typeof prisma.{= prismaIdentifier =},
{=/ allEntities =}
}
}
) => Promise<void> | void
export interface WaspSocketData {
user?: any
}
export type ServerType = Parameters<WebSocketFn>[0]
export type ClientToServerEvents = Events[0]
export type ServerToClientEvents = Events[1]
type WebSocketFn = typeof {= userWebSocketFn.importIdentifier =}
type Events = ServerType extends Server<
infer ClientToServerEvents,
infer ServerToClientEvents
>
? [ClientToServerEvents, ServerToClientEvents]
: [DefaultEventsMap, DefaultEventsMap]

View File

@ -0,0 +1,51 @@
{{={= =}=}}
import http from 'http'
import { Server, Socket } from 'socket.io'
import type { ServerType } from './index.js'
import config from '../config.js'
import prisma from '../dbClient.js'
{=# isAuthEnabled =}
import { getUserFromToken } from '../core/auth.js'
{=/ isAuthEnabled =}
{=& userWebSocketFn.importStatement =}
// Initializes the WebSocket server and invokes the user's WebSocket function.
export async function init(server: http.Server): Promise<void> {
// TODO: In the future, we can consider allowing a clustering option.
// Ref: https://github.com/wasp-lang/wasp/issues/1228
const io: ServerType = new Server(server, {
cors: {
origin: config.frontendUrl,
}
})
{=# isAuthEnabled =}
io.use(addUserToSocketDataIfAuthenticated)
{=/ isAuthEnabled =}
const context = {
entities: {
{=# allEntities =}
{= name =}: prisma.{= prismaIdentifier =},
{=/ allEntities =}
}
}
await {= userWebSocketFn.importIdentifier =}(io, context)
}
{=# isAuthEnabled =}
async function addUserToSocketDataIfAuthenticated(socket: Socket, next: (err?: Error) => void) {
const token = socket.handshake.auth.token
if (token) {
try {
socket.data = { ...socket.data, user: await getUserFromToken(token) }
} catch (err) { }
}
next()
}
{=/ isAuthEnabled =}

View File

@ -51,6 +51,7 @@ waspBuild/.wasp/build/web-app/src/actions/core.d.ts
waspBuild/.wasp/build/web-app/src/actions/core.js
waspBuild/.wasp/build/web-app/src/actions/index.ts
waspBuild/.wasp/build/web-app/src/api.ts
waspBuild/.wasp/build/web-app/src/api/events.ts
waspBuild/.wasp/build/web-app/src/config.js
waspBuild/.wasp/build/web-app/src/entities/index.ts
waspBuild/.wasp/build/web-app/src/ext-src/Main.css

View File

@ -53,7 +53,7 @@
"file",
"server/package.json"
],
"bd360a5b63026da11ee45ad35576fef287e290f6428e7cfb698224dd4bc3b6f7"
"bc09642f90f43c75f083e06fe580fee34b73b7fcb17e5cdec9f7fd17bdc67b08"
],
[
[
@ -235,7 +235,7 @@
"file",
"server/src/server.ts"
],
"c0edaf956cf6d6f8424c22216b1e464f2f900ce672274fabf670ec4378ca3a95"
"1c5af223cf0309b341e87cf8b6afd58b0cb21217e64cd9ee498048136a9da5be"
],
[
[
@ -312,7 +312,7 @@
"file",
"web-app/package.json"
],
"4b5d9bfcc368b9d85ca7984e3673ba303d97392ff1653890ae8f62212e676e91"
"ee9766b7c88b3d4ac36b1dd4b3237ea750e4c26ad27bcd18006225707d042e18"
],
[
[
@ -368,7 +368,14 @@
"file",
"web-app/src/api.ts"
],
"ebe9b49e262c56942f61d48082905cd3715d2da6bee945f7f7201dd1641720f9"
"93118387834981574ce1773d33275308e68ef8ca87408a35be8931c44a8889bf"
],
[
[
"file",
"web-app/src/api/events.ts"
],
"7220e570cfb823028ad6c076cbcf033d217acfb88537bcac47020f1085757044"
],
[
[
@ -417,7 +424,7 @@
"file",
"web-app/src/index.tsx"
],
"66f46f0148c1d1ec31784dc40252f93fcd3e9dacb6b6d74652a5aae5edc99e44"
"d10c443130afd9848fcfa631424590784e70dc1c66a8b7a9a8c1dfa9dd7ad5df"
],
[
[
@ -494,7 +501,7 @@
"file",
"web-app/src/storage.ts"
],
"1e35eb73e486c8f926337a8c8ddfc392639de3718bf28fdc3073b0ca97c864f7"
"e9e2a4a02d48bea1597fcdc00592e3d975eea7fa6e0545cf087da9429c5f4979"
],
[
[

View File

@ -1 +1 @@
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}

View File

@ -17,9 +17,11 @@
},
"devDependencies": {
"@tsconfig/node18": "^1.0.1",
"@types/cors": "^2.8.5",
"@types/express": "^4.17.13",
"@types/express-serve-static-core": "^4.17.13",
"@types/node": "^18.11.9",
"@types/uuid": "^9.0.0",
"nodemon": "^2.0.19",
"prisma": "4.12.0",
"standard": "^17.0.0",

View File

@ -5,6 +5,7 @@ import config from './config.js'
const startServer = async () => {
const port = normalizePort(config.port)
@ -13,6 +14,7 @@ const startServer = async () => {
const server = http.createServer(app)
server.listen(port)
server.on('error', (error: NodeJS.ErrnoException) => {

View File

@ -15,6 +15,7 @@
"@prisma/client": "4.12.0",
"@tanstack/react-query": "^4.29.0",
"axios": "^1.4.0",
"mitt": "3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^5.3.3",

View File

@ -1,6 +1,8 @@
import axios, { type AxiosError } from 'axios'
import config from './config'
import { storage } from './storage'
import { apiEventsEmitter } from './api/events'
const api = axios.create({
baseURL: config.apiUrl,
@ -10,20 +12,26 @@ const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
export function setAuthToken (token: string): void {
export function setAuthToken(token: string): void {
authToken = token
storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
apiEventsEmitter.emit('authToken.set')
}
export function getAuthToken(): string | undefined {
return authToken
}
export function clearAuthToken(): void {
authToken = undefined
storage.remove(WASP_APP_AUTH_TOKEN_NAME)
apiEventsEmitter.emit('authToken.clear')
}
export function removeLocalUserData(): void {
authToken = undefined
storage.clear()
apiEventsEmitter.emit('authToken.clear')
}
api.interceptors.request.use((request) => {
@ -40,6 +48,23 @@ api.interceptors.response.use(undefined, (error) => {
return Promise.reject(error)
})
// This handler will run on other tabs (not the active one calling API functions),
// and will ensure they know about auth token changes.
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
// "Note: This won't work on the same page that is making the changes — it is really a way
// for other pages on the domain using the storage to sync any changes that are made."
window.addEventListener('storage', (event) => {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) {
if (!!event.newValue) {
authToken = event.newValue
apiEventsEmitter.emit('authToken.set')
} else {
authToken = undefined
apiEventsEmitter.emit('authToken.clear')
}
}
})
/**
* Takes an error returned by the app's API (as returned by axios), and transforms into a more
* standard format to be further used by the client. It is also assumed that given API

View File

@ -0,0 +1,11 @@
import mitt, { Emitter } from 'mitt';
type ApiEvents = {
// key: Event name
// type: Event payload type
'authToken.set': void;
'authToken.clear': void;
};
// Used to allow API clients to register for auth token change events.
export const apiEventsEmitter: Emitter<ApiEvents> = mitt<ApiEvents>();

View File

@ -9,6 +9,7 @@ import {
} from './queryClient'
startApp()
async function startApp() {

View File

@ -1,44 +1,50 @@
export type DataStore = {
set(key: string, value: unknown): void;
get(key: string): unknown;
remove(key: string): void;
clear(): void;
};
getPrefixedKey(key: string): string
set(key: string, value: unknown): void
get(key: string): unknown
remove(key: string): void
clear(): void
}
function createLocalStorageDataStore(prefix: string): DataStore {
return {
set(key, value) {
ensureLocalStorageIsAvailable();
localStorage.setItem(`${prefix}:${key}`, JSON.stringify(value));
},
get(key) {
ensureLocalStorageIsAvailable();
const value = localStorage.getItem(`${prefix}:${key}`);
try {
return value ? JSON.parse(value) : undefined;
} catch (e: any) {
return undefined;
}
},
remove(key) {
ensureLocalStorageIsAvailable();
localStorage.removeItem(`${prefix}:${key}`);
},
clear() {
ensureLocalStorageIsAvailable();
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(prefix)) {
localStorage.removeItem(key);
}
});
},
};
function getPrefixedKey(key: string): string {
return `${prefix}:${key}`
}
return {
getPrefixedKey,
set(key, value) {
ensureLocalStorageIsAvailable()
localStorage.setItem(getPrefixedKey(key), JSON.stringify(value))
},
get(key) {
ensureLocalStorageIsAvailable()
const value = localStorage.getItem(getPrefixedKey(key))
try {
return value ? JSON.parse(value) : undefined
} catch (e: any) {
return undefined
}
},
remove(key) {
ensureLocalStorageIsAvailable()
localStorage.removeItem(getPrefixedKey(key))
},
clear() {
ensureLocalStorageIsAvailable()
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(prefix)) {
localStorage.removeItem(key)
}
})
},
}
}
export const storage = createLocalStorageDataStore('wasp');
export const storage = createLocalStorageDataStore('wasp')
function ensureLocalStorageIsAvailable(): void {
if (!window.localStorage) {
throw new Error('Local storage is not available.');
}
if (!window.localStorage) {
throw new Error('Local storage is not available.')
}
}

View File

@ -53,6 +53,7 @@ waspCompile/.wasp/out/web-app/src/actions/core.d.ts
waspCompile/.wasp/out/web-app/src/actions/core.js
waspCompile/.wasp/out/web-app/src/actions/index.ts
waspCompile/.wasp/out/web-app/src/api.ts
waspCompile/.wasp/out/web-app/src/api/events.ts
waspCompile/.wasp/out/web-app/src/config.js
waspCompile/.wasp/out/web-app/src/entities/index.ts
waspCompile/.wasp/out/web-app/src/ext-src/Main.css

View File

@ -60,7 +60,7 @@
"file",
"server/package.json"
],
"bd360a5b63026da11ee45ad35576fef287e290f6428e7cfb698224dd4bc3b6f7"
"bc09642f90f43c75f083e06fe580fee34b73b7fcb17e5cdec9f7fd17bdc67b08"
],
[
[
@ -242,7 +242,7 @@
"file",
"server/src/server.ts"
],
"c0edaf956cf6d6f8424c22216b1e464f2f900ce672274fabf670ec4378ca3a95"
"1c5af223cf0309b341e87cf8b6afd58b0cb21217e64cd9ee498048136a9da5be"
],
[
[
@ -326,7 +326,7 @@
"file",
"web-app/package.json"
],
"e7b34e8e24ad90b09099eebc50c83c5260a09300156efc428e332fc27dfa8402"
"8a2249588d7cf9ac7c6c8cb979727c264446581f60c7062e5852aef4c1d8a675"
],
[
[
@ -382,7 +382,14 @@
"file",
"web-app/src/api.ts"
],
"ebe9b49e262c56942f61d48082905cd3715d2da6bee945f7f7201dd1641720f9"
"93118387834981574ce1773d33275308e68ef8ca87408a35be8931c44a8889bf"
],
[
[
"file",
"web-app/src/api/events.ts"
],
"7220e570cfb823028ad6c076cbcf033d217acfb88537bcac47020f1085757044"
],
[
[
@ -431,7 +438,7 @@
"file",
"web-app/src/index.tsx"
],
"66f46f0148c1d1ec31784dc40252f93fcd3e9dacb6b6d74652a5aae5edc99e44"
"d10c443130afd9848fcfa631424590784e70dc1c66a8b7a9a8c1dfa9dd7ad5df"
],
[
[
@ -508,7 +515,7 @@
"file",
"web-app/src/storage.ts"
],
"1e35eb73e486c8f926337a8c8ddfc392639de3718bf28fdc3073b0ca97c864f7"
"e9e2a4a02d48bea1597fcdc00592e3d975eea7fa6e0545cf087da9429c5f4979"
],
[
[

View File

@ -1 +1 @@
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}

View File

@ -17,9 +17,11 @@
},
"devDependencies": {
"@tsconfig/node18": "^1.0.1",
"@types/cors": "^2.8.5",
"@types/express": "^4.17.13",
"@types/express-serve-static-core": "^4.17.13",
"@types/node": "^18.11.9",
"@types/uuid": "^9.0.0",
"nodemon": "^2.0.19",
"prisma": "4.12.0",
"standard": "^17.0.0",

View File

@ -5,6 +5,7 @@ import config from './config.js'
const startServer = async () => {
const port = normalizePort(config.port)
@ -13,6 +14,7 @@ const startServer = async () => {
const server = http.createServer(app)
server.listen(port)
server.on('error', (error: NodeJS.ErrnoException) => {

View File

@ -15,6 +15,7 @@
"@prisma/client": "4.12.0",
"@tanstack/react-query": "^4.29.0",
"axios": "^1.4.0",
"mitt": "3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^5.3.3",

View File

@ -1,6 +1,8 @@
import axios, { type AxiosError } from 'axios'
import config from './config'
import { storage } from './storage'
import { apiEventsEmitter } from './api/events'
const api = axios.create({
baseURL: config.apiUrl,
@ -10,20 +12,26 @@ const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
export function setAuthToken (token: string): void {
export function setAuthToken(token: string): void {
authToken = token
storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
apiEventsEmitter.emit('authToken.set')
}
export function getAuthToken(): string | undefined {
return authToken
}
export function clearAuthToken(): void {
authToken = undefined
storage.remove(WASP_APP_AUTH_TOKEN_NAME)
apiEventsEmitter.emit('authToken.clear')
}
export function removeLocalUserData(): void {
authToken = undefined
storage.clear()
apiEventsEmitter.emit('authToken.clear')
}
api.interceptors.request.use((request) => {
@ -40,6 +48,23 @@ api.interceptors.response.use(undefined, (error) => {
return Promise.reject(error)
})
// This handler will run on other tabs (not the active one calling API functions),
// and will ensure they know about auth token changes.
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
// "Note: This won't work on the same page that is making the changes — it is really a way
// for other pages on the domain using the storage to sync any changes that are made."
window.addEventListener('storage', (event) => {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) {
if (!!event.newValue) {
authToken = event.newValue
apiEventsEmitter.emit('authToken.set')
} else {
authToken = undefined
apiEventsEmitter.emit('authToken.clear')
}
}
})
/**
* Takes an error returned by the app's API (as returned by axios), and transforms into a more
* standard format to be further used by the client. It is also assumed that given API

View File

@ -0,0 +1,11 @@
import mitt, { Emitter } from 'mitt';
type ApiEvents = {
// key: Event name
// type: Event payload type
'authToken.set': void;
'authToken.clear': void;
};
// Used to allow API clients to register for auth token change events.
export const apiEventsEmitter: Emitter<ApiEvents> = mitt<ApiEvents>();

View File

@ -9,6 +9,7 @@ import {
} from './queryClient'
startApp()
async function startApp() {

View File

@ -1,44 +1,50 @@
export type DataStore = {
set(key: string, value: unknown): void;
get(key: string): unknown;
remove(key: string): void;
clear(): void;
};
getPrefixedKey(key: string): string
set(key: string, value: unknown): void
get(key: string): unknown
remove(key: string): void
clear(): void
}
function createLocalStorageDataStore(prefix: string): DataStore {
return {
set(key, value) {
ensureLocalStorageIsAvailable();
localStorage.setItem(`${prefix}:${key}`, JSON.stringify(value));
},
get(key) {
ensureLocalStorageIsAvailable();
const value = localStorage.getItem(`${prefix}:${key}`);
try {
return value ? JSON.parse(value) : undefined;
} catch (e: any) {
return undefined;
}
},
remove(key) {
ensureLocalStorageIsAvailable();
localStorage.removeItem(`${prefix}:${key}`);
},
clear() {
ensureLocalStorageIsAvailable();
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(prefix)) {
localStorage.removeItem(key);
}
});
},
};
function getPrefixedKey(key: string): string {
return `${prefix}:${key}`
}
return {
getPrefixedKey,
set(key, value) {
ensureLocalStorageIsAvailable()
localStorage.setItem(getPrefixedKey(key), JSON.stringify(value))
},
get(key) {
ensureLocalStorageIsAvailable()
const value = localStorage.getItem(getPrefixedKey(key))
try {
return value ? JSON.parse(value) : undefined
} catch (e: any) {
return undefined
}
},
remove(key) {
ensureLocalStorageIsAvailable()
localStorage.removeItem(getPrefixedKey(key))
},
clear() {
ensureLocalStorageIsAvailable()
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(prefix)) {
localStorage.removeItem(key)
}
})
},
}
}
export const storage = createLocalStorageDataStore('wasp');
export const storage = createLocalStorageDataStore('wasp')
function ensureLocalStorageIsAvailable(): void {
if (!window.localStorage) {
throw new Error('Local storage is not available.');
}
if (!window.localStorage) {
throw new Error('Local storage is not available.')
}
}

View File

@ -93,6 +93,7 @@ waspComplexTest/.wasp/out/web-app/src/actions/core.d.ts
waspComplexTest/.wasp/out/web-app/src/actions/core.js
waspComplexTest/.wasp/out/web-app/src/actions/index.ts
waspComplexTest/.wasp/out/web-app/src/api.ts
waspComplexTest/.wasp/out/web-app/src/api/events.ts
waspComplexTest/.wasp/out/web-app/src/auth/forms/Auth.tsx
waspComplexTest/.wasp/out/web-app/src/auth/forms/Login.tsx
waspComplexTest/.wasp/out/web-app/src/auth/forms/Signup.tsx

View File

@ -60,7 +60,7 @@
"file",
"server/package.json"
],
"43cf95d250c9baffd30a5f4836608965aa46d0875f4a736ad384a647639e9c30"
"ccb30cfeab8c8d895563611b57258317c19c31cc71aa31c478be4710c05a24a4"
],
[
[
@ -214,7 +214,7 @@
"file",
"server/src/core/auth.js"
],
"89d2e6b1693deb9cc243efd0bf680513d0860475a2655ef487c684eb2574a92f"
"cb1941ba655c0300bbabda6b51096acdfcd28665ba2f07de676bacb26bd8cddc"
],
[
[
@ -494,7 +494,7 @@
"file",
"server/src/server.ts"
],
"ea5208ac63e4705156c15454f29f53f21cf4d98da8b697f44739eb899dc7f2e5"
"93c05fac0fb2e30eeda90dbb374bfa5c7fcb860b4605da8ae2c6b6f913f95963"
],
[
[
@ -578,7 +578,7 @@
"file",
"web-app/package.json"
],
"aec3fd9668221023b165909ee2321225cff44a36c9a7fcdd7e15bb30111b9900"
"fc5d4d8e4a3ed36972eac6891122dfd3d8edb4074a8df041162dcb38bb6f6d2d"
],
[
[
@ -641,7 +641,14 @@
"file",
"web-app/src/api.ts"
],
"ebe9b49e262c56942f61d48082905cd3715d2da6bee945f7f7201dd1641720f9"
"93118387834981574ce1773d33275308e68ef8ca87408a35be8931c44a8889bf"
],
[
[
"file",
"web-app/src/api/events.ts"
],
"7220e570cfb823028ad6c076cbcf033d217acfb88537bcac47020f1085757044"
],
[
[
@ -823,7 +830,7 @@
"file",
"web-app/src/index.tsx"
],
"f84afcc81bea674e7e3d88e8279d514d1838c4aecb4357c8c3b1fc8ee9879281"
"db4c0dd228be7d2dad2ad92b03719b9c0508196a9ced4dfef8f1690c154669e3"
],
[
[
@ -914,7 +921,7 @@
"file",
"web-app/src/storage.ts"
],
"1e35eb73e486c8f926337a8c8ddfc392639de3718bf28fdc3073b0ca97c864f7"
"e9e2a4a02d48bea1597fcdc00592e3d975eea7fa6e0545cf087da9429c5f4979"
],
[
[

View File

@ -1 +1 @@
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"},{"name":"passport","version":"0.6.0"},{"name":"passport-google-oauth20","version":"2.0.0"},{"name":"pg-boss","version":"^8.4.2"},{"name":"@sendgrid/mail","version":"^7.7.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"@stitches/react","version":"^1.2.8"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"},{"name":"passport","version":"0.6.0"},{"name":"passport-google-oauth20","version":"2.0.0"},{"name":"pg-boss","version":"^8.4.2"},{"name":"@sendgrid/mail","version":"^7.7.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"},{"name":"@stitches/react","version":"^1.2.8"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}

View File

@ -23,9 +23,11 @@
},
"devDependencies": {
"@tsconfig/node18": "^1.0.1",
"@types/cors": "^2.8.5",
"@types/express": "^4.17.13",
"@types/express-serve-static-core": "^4.17.13",
"@types/node": "^18.11.9",
"@types/uuid": "^9.0.0",
"nodemon": "^2.0.19",
"prisma": "4.12.0",
"standard": "^17.0.0",

View File

@ -27,30 +27,7 @@ const auth = handleRejection(async (req, res, next) => {
if (authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7, authHeader.length)
let userIdFromToken
try {
userIdFromToken = (await verify(token)).id
} catch (error) {
if (['TokenExpiredError', 'JsonWebTokenError', 'NotBeforeError'].includes(error.name)) {
throwInvalidCredentialsError()
} else {
throw error
}
}
const user = await prisma.user.findUnique({ where: { id: userIdFromToken } })
if (!user) {
throwInvalidCredentialsError()
}
// TODO: This logic must match the type in types/index.ts (if we remove the
// password field from the object here, we must to do the same there).
// Ideally, these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
const { password, ...userView } = user
req.user = userView
req.user = await getUserFromToken(token)
} else {
throwInvalidCredentialsError()
}
@ -58,6 +35,32 @@ const auth = handleRejection(async (req, res, next) => {
next()
})
export async function getUserFromToken(token) {
let userIdFromToken
try {
userIdFromToken = (await verify(token)).id
} catch (error) {
if (['TokenExpiredError', 'JsonWebTokenError', 'NotBeforeError'].includes(error.name)) {
throwInvalidCredentialsError()
} else {
throw error
}
}
const user = await prisma.user.findUnique({ where: { id: userIdFromToken } })
if (!user) {
throwInvalidCredentialsError()
}
// TODO: This logic must match the type in types/index.ts (if we remove the
// password field from the object here, we must to do the same there).
// Ideally, these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
const { password, ...userView } = user
return userView
}
const SP = new SecurePassword()
export const hashPassword = async (password) => {

View File

@ -9,6 +9,7 @@ import { ServerSetupFn, ServerSetupFnContext } from './types'
import { startPgBoss } from './jobs/core/pgBoss/pgBoss.js'
import './jobs/core/allJobs.js'
const startServer = async () => {
await startPgBoss()
@ -20,6 +21,7 @@ const startServer = async () => {
const serverSetupFnContext: ServerSetupFnContext = { app, server }
await (mySetupFunction as ServerSetupFn)(serverSetupFnContext)
server.listen(port)
server.on('error', (error: NodeJS.ErrnoException) => {

View File

@ -16,6 +16,7 @@
"@stitches/react": "^1.2.8",
"@tanstack/react-query": "^4.29.0",
"axios": "^1.4.0",
"mitt": "3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^7.1.3",

View File

@ -1,6 +1,8 @@
import axios, { type AxiosError } from 'axios'
import config from './config'
import { storage } from './storage'
import { apiEventsEmitter } from './api/events'
const api = axios.create({
baseURL: config.apiUrl,
@ -10,20 +12,26 @@ const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
export function setAuthToken (token: string): void {
export function setAuthToken(token: string): void {
authToken = token
storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
apiEventsEmitter.emit('authToken.set')
}
export function getAuthToken(): string | undefined {
return authToken
}
export function clearAuthToken(): void {
authToken = undefined
storage.remove(WASP_APP_AUTH_TOKEN_NAME)
apiEventsEmitter.emit('authToken.clear')
}
export function removeLocalUserData(): void {
authToken = undefined
storage.clear()
apiEventsEmitter.emit('authToken.clear')
}
api.interceptors.request.use((request) => {
@ -40,6 +48,23 @@ api.interceptors.response.use(undefined, (error) => {
return Promise.reject(error)
})
// This handler will run on other tabs (not the active one calling API functions),
// and will ensure they know about auth token changes.
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
// "Note: This won't work on the same page that is making the changes — it is really a way
// for other pages on the domain using the storage to sync any changes that are made."
window.addEventListener('storage', (event) => {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) {
if (!!event.newValue) {
authToken = event.newValue
apiEventsEmitter.emit('authToken.set')
} else {
authToken = undefined
apiEventsEmitter.emit('authToken.clear')
}
}
})
/**
* Takes an error returned by the app's API (as returned by axios), and transforms into a more
* standard format to be further used by the client. It is also assumed that given API

View File

@ -0,0 +1,11 @@
import mitt, { Emitter } from 'mitt';
type ApiEvents = {
// key: Event name
// type: Event payload type
'authToken.set': void;
'authToken.clear': void;
};
// Used to allow API clients to register for auth token change events.
export const apiEventsEmitter: Emitter<ApiEvents> = mitt<ApiEvents>();

View File

@ -10,6 +10,7 @@ import {
import myClientSetupFunction from './ext-src/myClientSetupCode.js'
startApp()
async function startApp() {

View File

@ -1,44 +1,50 @@
export type DataStore = {
set(key: string, value: unknown): void;
get(key: string): unknown;
remove(key: string): void;
clear(): void;
};
getPrefixedKey(key: string): string
set(key: string, value: unknown): void
get(key: string): unknown
remove(key: string): void
clear(): void
}
function createLocalStorageDataStore(prefix: string): DataStore {
return {
set(key, value) {
ensureLocalStorageIsAvailable();
localStorage.setItem(`${prefix}:${key}`, JSON.stringify(value));
},
get(key) {
ensureLocalStorageIsAvailable();
const value = localStorage.getItem(`${prefix}:${key}`);
try {
return value ? JSON.parse(value) : undefined;
} catch (e: any) {
return undefined;
}
},
remove(key) {
ensureLocalStorageIsAvailable();
localStorage.removeItem(`${prefix}:${key}`);
},
clear() {
ensureLocalStorageIsAvailable();
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(prefix)) {
localStorage.removeItem(key);
}
});
},
};
function getPrefixedKey(key: string): string {
return `${prefix}:${key}`
}
return {
getPrefixedKey,
set(key, value) {
ensureLocalStorageIsAvailable()
localStorage.setItem(getPrefixedKey(key), JSON.stringify(value))
},
get(key) {
ensureLocalStorageIsAvailable()
const value = localStorage.getItem(getPrefixedKey(key))
try {
return value ? JSON.parse(value) : undefined
} catch (e: any) {
return undefined
}
},
remove(key) {
ensureLocalStorageIsAvailable()
localStorage.removeItem(getPrefixedKey(key))
},
clear() {
ensureLocalStorageIsAvailable()
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(prefix)) {
localStorage.removeItem(key)
}
})
},
}
}
export const storage = createLocalStorageDataStore('wasp');
export const storage = createLocalStorageDataStore('wasp')
function ensureLocalStorageIsAvailable(): void {
if (!window.localStorage) {
throw new Error('Local storage is not available.');
}
if (!window.localStorage) {
throw new Error('Local storage is not available.')
}
}

View File

@ -56,6 +56,7 @@ waspJob/.wasp/out/web-app/src/actions/core.d.ts
waspJob/.wasp/out/web-app/src/actions/core.js
waspJob/.wasp/out/web-app/src/actions/index.ts
waspJob/.wasp/out/web-app/src/api.ts
waspJob/.wasp/out/web-app/src/api/events.ts
waspJob/.wasp/out/web-app/src/config.js
waspJob/.wasp/out/web-app/src/entities/index.ts
waspJob/.wasp/out/web-app/src/ext-src/Main.css

View File

@ -60,7 +60,7 @@
"file",
"server/package.json"
],
"8be0f0291cb3f9d670ce83bd0fda3f1115fa544313bcc2061174397ef41e4332"
"33830187439118a2922c175a6556ac8294102e5e120ee3a5a314a4b63ede3490"
],
[
[
@ -256,7 +256,7 @@
"file",
"server/src/server.ts"
],
"29faf0c794094c17733a6f09ae2a9c7eddab234d51b36f1518c1fb89cb9bfbb8"
"e28a2e72f8a0cedbfba8c6acfbc36b9ae35db9005aa26484d88ef7e7688efa5b"
],
[
[
@ -340,7 +340,7 @@
"file",
"web-app/package.json"
],
"cccc16af72075b76173f9d67f846cb89cc1f3f5d3dddcc97706b7a3600adf8d6"
"57da965d01f5d39d74c6ca91f00d84ba5b6c78660ee837266b2622e410e4fd8e"
],
[
[
@ -396,7 +396,14 @@
"file",
"web-app/src/api.ts"
],
"ebe9b49e262c56942f61d48082905cd3715d2da6bee945f7f7201dd1641720f9"
"93118387834981574ce1773d33275308e68ef8ca87408a35be8931c44a8889bf"
],
[
[
"file",
"web-app/src/api/events.ts"
],
"7220e570cfb823028ad6c076cbcf033d217acfb88537bcac47020f1085757044"
],
[
[
@ -445,7 +452,7 @@
"file",
"web-app/src/index.tsx"
],
"66f46f0148c1d1ec31784dc40252f93fcd3e9dacb6b6d74652a5aae5edc99e44"
"d10c443130afd9848fcfa631424590784e70dc1c66a8b7a9a8c1dfa9dd7ad5df"
],
[
[
@ -522,7 +529,7 @@
"file",
"web-app/src/storage.ts"
],
"1e35eb73e486c8f926337a8c8ddfc392639de3718bf28fdc3073b0ca97c864f7"
"e9e2a4a02d48bea1597fcdc00592e3d975eea7fa6e0545cf087da9429c5f4979"
],
[
[

View File

@ -1 +1 @@
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"},{"name":"pg-boss","version":"^8.4.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"},{"name":"pg-boss","version":"^8.4.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}

View File

@ -18,9 +18,11 @@
},
"devDependencies": {
"@tsconfig/node18": "^1.0.1",
"@types/cors": "^2.8.5",
"@types/express": "^4.17.13",
"@types/express-serve-static-core": "^4.17.13",
"@types/node": "^18.11.9",
"@types/uuid": "^9.0.0",
"nodemon": "^2.0.19",
"prisma": "4.12.0",
"standard": "^17.0.0",

View File

@ -7,6 +7,7 @@ import config from './config.js'
import { startPgBoss } from './jobs/core/pgBoss/pgBoss.js'
import './jobs/core/allJobs.js'
const startServer = async () => {
await startPgBoss()
@ -16,6 +17,7 @@ const startServer = async () => {
const server = http.createServer(app)
server.listen(port)
server.on('error', (error: NodeJS.ErrnoException) => {

View File

@ -15,6 +15,7 @@
"@prisma/client": "4.12.0",
"@tanstack/react-query": "^4.29.0",
"axios": "^1.4.0",
"mitt": "3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^5.3.3",

View File

@ -1,6 +1,8 @@
import axios, { type AxiosError } from 'axios'
import config from './config'
import { storage } from './storage'
import { apiEventsEmitter } from './api/events'
const api = axios.create({
baseURL: config.apiUrl,
@ -10,20 +12,26 @@ const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
export function setAuthToken (token: string): void {
export function setAuthToken(token: string): void {
authToken = token
storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
apiEventsEmitter.emit('authToken.set')
}
export function getAuthToken(): string | undefined {
return authToken
}
export function clearAuthToken(): void {
authToken = undefined
storage.remove(WASP_APP_AUTH_TOKEN_NAME)
apiEventsEmitter.emit('authToken.clear')
}
export function removeLocalUserData(): void {
authToken = undefined
storage.clear()
apiEventsEmitter.emit('authToken.clear')
}
api.interceptors.request.use((request) => {
@ -40,6 +48,23 @@ api.interceptors.response.use(undefined, (error) => {
return Promise.reject(error)
})
// This handler will run on other tabs (not the active one calling API functions),
// and will ensure they know about auth token changes.
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
// "Note: This won't work on the same page that is making the changes — it is really a way
// for other pages on the domain using the storage to sync any changes that are made."
window.addEventListener('storage', (event) => {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) {
if (!!event.newValue) {
authToken = event.newValue
apiEventsEmitter.emit('authToken.set')
} else {
authToken = undefined
apiEventsEmitter.emit('authToken.clear')
}
}
})
/**
* Takes an error returned by the app's API (as returned by axios), and transforms into a more
* standard format to be further used by the client. It is also assumed that given API

View File

@ -0,0 +1,11 @@
import mitt, { Emitter } from 'mitt';
type ApiEvents = {
// key: Event name
// type: Event payload type
'authToken.set': void;
'authToken.clear': void;
};
// Used to allow API clients to register for auth token change events.
export const apiEventsEmitter: Emitter<ApiEvents> = mitt<ApiEvents>();

View File

@ -9,6 +9,7 @@ import {
} from './queryClient'
startApp()
async function startApp() {

View File

@ -1,44 +1,50 @@
export type DataStore = {
set(key: string, value: unknown): void;
get(key: string): unknown;
remove(key: string): void;
clear(): void;
};
getPrefixedKey(key: string): string
set(key: string, value: unknown): void
get(key: string): unknown
remove(key: string): void
clear(): void
}
function createLocalStorageDataStore(prefix: string): DataStore {
return {
set(key, value) {
ensureLocalStorageIsAvailable();
localStorage.setItem(`${prefix}:${key}`, JSON.stringify(value));
},
get(key) {
ensureLocalStorageIsAvailable();
const value = localStorage.getItem(`${prefix}:${key}`);
try {
return value ? JSON.parse(value) : undefined;
} catch (e: any) {
return undefined;
}
},
remove(key) {
ensureLocalStorageIsAvailable();
localStorage.removeItem(`${prefix}:${key}`);
},
clear() {
ensureLocalStorageIsAvailable();
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(prefix)) {
localStorage.removeItem(key);
}
});
},
};
function getPrefixedKey(key: string): string {
return `${prefix}:${key}`
}
return {
getPrefixedKey,
set(key, value) {
ensureLocalStorageIsAvailable()
localStorage.setItem(getPrefixedKey(key), JSON.stringify(value))
},
get(key) {
ensureLocalStorageIsAvailable()
const value = localStorage.getItem(getPrefixedKey(key))
try {
return value ? JSON.parse(value) : undefined
} catch (e: any) {
return undefined
}
},
remove(key) {
ensureLocalStorageIsAvailable()
localStorage.removeItem(getPrefixedKey(key))
},
clear() {
ensureLocalStorageIsAvailable()
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(prefix)) {
localStorage.removeItem(key)
}
})
},
}
}
export const storage = createLocalStorageDataStore('wasp');
export const storage = createLocalStorageDataStore('wasp')
function ensureLocalStorageIsAvailable(): void {
if (!window.localStorage) {
throw new Error('Local storage is not available.');
}
if (!window.localStorage) {
throw new Error('Local storage is not available.')
}
}

View File

@ -58,6 +58,7 @@ waspMigrate/.wasp/out/web-app/src/actions/core.d.ts
waspMigrate/.wasp/out/web-app/src/actions/core.js
waspMigrate/.wasp/out/web-app/src/actions/index.ts
waspMigrate/.wasp/out/web-app/src/api.ts
waspMigrate/.wasp/out/web-app/src/api/events.ts
waspMigrate/.wasp/out/web-app/src/config.js
waspMigrate/.wasp/out/web-app/src/entities/index.ts
waspMigrate/.wasp/out/web-app/src/ext-src/Main.css

View File

@ -60,7 +60,7 @@
"file",
"server/package.json"
],
"8c85fddc411bb05761693a87b977ba0bb18acfbe98b89c0b2b84ff292c7a19b1"
"9e236107744f6575b087fc5f5250b2b95202d3a1bfa55c94c7ba99532d76383a"
],
[
[
@ -242,7 +242,7 @@
"file",
"server/src/server.ts"
],
"c0edaf956cf6d6f8424c22216b1e464f2f900ce672274fabf670ec4378ca3a95"
"1c5af223cf0309b341e87cf8b6afd58b0cb21217e64cd9ee498048136a9da5be"
],
[
[
@ -326,7 +326,7 @@
"file",
"web-app/package.json"
],
"7e92d29215f0a0492818b0bbb68265d4921f065f9ec51e3c2ff5e9f083ecc03f"
"7e237189e89ac549b485ffef329aaa6699c874fd16b8e890f37f412ad7715219"
],
[
[
@ -382,7 +382,14 @@
"file",
"web-app/src/api.ts"
],
"ebe9b49e262c56942f61d48082905cd3715d2da6bee945f7f7201dd1641720f9"
"93118387834981574ce1773d33275308e68ef8ca87408a35be8931c44a8889bf"
],
[
[
"file",
"web-app/src/api/events.ts"
],
"7220e570cfb823028ad6c076cbcf033d217acfb88537bcac47020f1085757044"
],
[
[
@ -431,7 +438,7 @@
"file",
"web-app/src/index.tsx"
],
"66f46f0148c1d1ec31784dc40252f93fcd3e9dacb6b6d74652a5aae5edc99e44"
"d10c443130afd9848fcfa631424590784e70dc1c66a8b7a9a8c1dfa9dd7ad5df"
],
[
[
@ -508,7 +515,7 @@
"file",
"web-app/src/storage.ts"
],
"1e35eb73e486c8f926337a8c8ddfc392639de3718bf28fdc3073b0ca97c864f7"
"e9e2a4a02d48bea1597fcdc00592e3d975eea7fa6e0545cf087da9429c5f4979"
],
[
[

View File

@ -1 +1 @@
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}

View File

@ -17,9 +17,11 @@
},
"devDependencies": {
"@tsconfig/node18": "^1.0.1",
"@types/cors": "^2.8.5",
"@types/express": "^4.17.13",
"@types/express-serve-static-core": "^4.17.13",
"@types/node": "^18.11.9",
"@types/uuid": "^9.0.0",
"nodemon": "^2.0.19",
"prisma": "4.12.0",
"standard": "^17.0.0",

View File

@ -5,6 +5,7 @@ import config from './config.js'
const startServer = async () => {
const port = normalizePort(config.port)
@ -13,6 +14,7 @@ const startServer = async () => {
const server = http.createServer(app)
server.listen(port)
server.on('error', (error: NodeJS.ErrnoException) => {

View File

@ -15,6 +15,7 @@
"@prisma/client": "4.12.0",
"@tanstack/react-query": "^4.29.0",
"axios": "^1.4.0",
"mitt": "3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^5.3.3",

View File

@ -1,6 +1,8 @@
import axios, { type AxiosError } from 'axios'
import config from './config'
import { storage } from './storage'
import { apiEventsEmitter } from './api/events'
const api = axios.create({
baseURL: config.apiUrl,
@ -10,20 +12,26 @@ const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
export function setAuthToken (token: string): void {
export function setAuthToken(token: string): void {
authToken = token
storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
apiEventsEmitter.emit('authToken.set')
}
export function getAuthToken(): string | undefined {
return authToken
}
export function clearAuthToken(): void {
authToken = undefined
storage.remove(WASP_APP_AUTH_TOKEN_NAME)
apiEventsEmitter.emit('authToken.clear')
}
export function removeLocalUserData(): void {
authToken = undefined
storage.clear()
apiEventsEmitter.emit('authToken.clear')
}
api.interceptors.request.use((request) => {
@ -40,6 +48,23 @@ api.interceptors.response.use(undefined, (error) => {
return Promise.reject(error)
})
// This handler will run on other tabs (not the active one calling API functions),
// and will ensure they know about auth token changes.
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
// "Note: This won't work on the same page that is making the changes — it is really a way
// for other pages on the domain using the storage to sync any changes that are made."
window.addEventListener('storage', (event) => {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) {
if (!!event.newValue) {
authToken = event.newValue
apiEventsEmitter.emit('authToken.set')
} else {
authToken = undefined
apiEventsEmitter.emit('authToken.clear')
}
}
})
/**
* Takes an error returned by the app's API (as returned by axios), and transforms into a more
* standard format to be further used by the client. It is also assumed that given API

View File

@ -0,0 +1,11 @@
import mitt, { Emitter } from 'mitt';
type ApiEvents = {
// key: Event name
// type: Event payload type
'authToken.set': void;
'authToken.clear': void;
};
// Used to allow API clients to register for auth token change events.
export const apiEventsEmitter: Emitter<ApiEvents> = mitt<ApiEvents>();

View File

@ -9,6 +9,7 @@ import {
} from './queryClient'
startApp()
async function startApp() {

View File

@ -1,44 +1,50 @@
export type DataStore = {
set(key: string, value: unknown): void;
get(key: string): unknown;
remove(key: string): void;
clear(): void;
};
getPrefixedKey(key: string): string
set(key: string, value: unknown): void
get(key: string): unknown
remove(key: string): void
clear(): void
}
function createLocalStorageDataStore(prefix: string): DataStore {
return {
set(key, value) {
ensureLocalStorageIsAvailable();
localStorage.setItem(`${prefix}:${key}`, JSON.stringify(value));
},
get(key) {
ensureLocalStorageIsAvailable();
const value = localStorage.getItem(`${prefix}:${key}`);
try {
return value ? JSON.parse(value) : undefined;
} catch (e: any) {
return undefined;
}
},
remove(key) {
ensureLocalStorageIsAvailable();
localStorage.removeItem(`${prefix}:${key}`);
},
clear() {
ensureLocalStorageIsAvailable();
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(prefix)) {
localStorage.removeItem(key);
}
});
},
};
function getPrefixedKey(key: string): string {
return `${prefix}:${key}`
}
return {
getPrefixedKey,
set(key, value) {
ensureLocalStorageIsAvailable()
localStorage.setItem(getPrefixedKey(key), JSON.stringify(value))
},
get(key) {
ensureLocalStorageIsAvailable()
const value = localStorage.getItem(getPrefixedKey(key))
try {
return value ? JSON.parse(value) : undefined
} catch (e: any) {
return undefined
}
},
remove(key) {
ensureLocalStorageIsAvailable()
localStorage.removeItem(getPrefixedKey(key))
},
clear() {
ensureLocalStorageIsAvailable()
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(prefix)) {
localStorage.removeItem(key)
}
})
},
}
}
export const storage = createLocalStorageDataStore('wasp');
export const storage = createLocalStorageDataStore('wasp')
function ensureLocalStorageIsAvailable(): void {
if (!window.localStorage) {
throw new Error('Local storage is not available.');
}
if (!window.localStorage) {
throw new Error('Local storage is not available.')
}
}

View File

@ -4,12 +4,16 @@ import logout from '@wasp/auth/logout'
import useAuth from '@wasp/auth/useAuth'
import { useQuery } from '@wasp/queries'
import getDate from '@wasp/queries/getDate'
import { useSocket } from '@wasp/webSocket'
import './Main.css'
export function App({ children }: any) {
const { data: user } = useAuth()
const { data: date } = useQuery(getDate)
const { isConnected } = useSocket()
const connectionIcon = isConnected ? '🟢' : '🔴'
return (
<div className="app border-spacing-2 p-4">
@ -17,7 +21,9 @@ export function App({ children }: any) {
<h1 className="font-bold text-3xl mb-5">
<Link to="/">ToDo App</Link>
</h1>
<h2>Your site was loaded at: {date?.toLocaleString()}</h2>
<h2>
Your site was loaded at: {date?.toLocaleString()} {connectionIcon}
</h2>
{user && (
<div className="flex gap-3 items-center">
<div>

View File

@ -1,7 +1,8 @@
import React, { useEffect } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { User } from '@wasp/auth/types'
import api from '@wasp/api'
import { useSocket, useSocketListener, ServerToClientPayload } from '@wasp/webSocket'
async function fetchCustomRoute() {
const res = await api.get('/foo/bar')
@ -13,10 +14,32 @@ export const ProfilePage = ({
}: {
user: User
}) => {
const [messages, setMessages] = useState<ServerToClientPayload<'chatMessage'>[]>([])
const { socket, isConnected } = useSocket()
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
fetchCustomRoute()
}, [])
useSocketListener('chatMessage', (msg) => setMessages((priorMessages) => [msg, ...priorMessages]))
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (inputRef.current !== null) {
socket.emit('chatMessage', inputRef.current.value)
inputRef.current.value = ''
}
}
const messageList = messages.map((msg) => (
<li key={msg.id}>
<em>{msg.username}</em>: {msg.text}
</li>
))
const connectionIcon = isConnected ? '🟢' : '🔴'
return (
<>
<h2>Profile page</h2>
@ -26,6 +49,22 @@ export const ProfilePage = ({
</div>
<br />
<Link to="/">Go to dashboard</Link>
<div>
<form onSubmit={handleSubmit}>
<div className="flex space-x-4 place-items-center">
<div>{connectionIcon}</div>
<div>
<input type="text" ref={inputRef} />
</div>
<div>
<button className="btn btn-primary" type="submit">
Submit
</button>
</div>
</div>
</form>
<ul>{messageList}</ul>
</div>
</>
)
}

View File

@ -0,0 +1,27 @@
import { v4 as uuidv4 } from 'uuid'
import { WebSocketDefinition } from '@wasp/webSocket'
export const webSocketFn: WebSocketDefinition<
ClientToServerEvents,
ServerToClientEvents,
InterServerEvents
> = (io, context) => {
io.on('connection', (socket) => {
const username =
socket.data.user?.email || socket.data.user?.username || 'unknown'
console.log('a user connected: ', username)
socket.on('chatMessage', async (msg) => {
console.log('message: ', msg)
io.emit('chatMessage', { id: uuidv4(), username, text: msg })
})
})
}
interface ServerToClientEvents {
chatMessage: (msg: { id: string; username: string; text: string }) => void
}
interface ClientToServerEvents {
chatMessage: (msg: string) => void
}
interface InterServerEvents {}

View File

@ -8,6 +8,10 @@ app todoApp {
("@tailwindcss/forms", "^0.5.3"),
("@tailwindcss/typography", "^0.5.7")
],
webSocket: {
fn: import { webSocketFn } from "@server/webSocket.js",
// autoConnect: false
},
auth: {
userEntity: User,
// externalAuthEntity: SocialLogin,

View File

@ -10,6 +10,7 @@ import Wasp.AppSpec.App.Dependency (Dependency)
import Wasp.AppSpec.App.EmailSender (EmailSender)
import Wasp.AppSpec.App.Server (Server)
import Wasp.AppSpec.App.Wasp (Wasp)
import Wasp.AppSpec.App.WebSocket (WebSocket)
import Wasp.AppSpec.Core.Decl (IsDecl)
data App = App
@ -21,7 +22,8 @@ data App = App
client :: Maybe Client,
db :: Maybe Db,
emailSender :: Maybe EmailSender,
dependencies :: Maybe [Dependency]
dependencies :: Maybe [Dependency],
webSocket :: Maybe WebSocket
}
deriving (Show, Eq, Data)

View File

@ -0,0 +1,15 @@
{-# LANGUAGE DeriveDataTypeable #-}
module Wasp.AppSpec.App.WebSocket
( WebSocket (..),
)
where
import Data.Data (Data)
import Wasp.AppSpec.ExtImport (ExtImport)
data WebSocket = WebSocket
{ fn :: ExtImport,
autoConnect :: Maybe Bool
}
deriving (Show, Eq, Data)

View File

@ -64,6 +64,7 @@ import Wasp.Generator.ServerGenerator.JobGenerator (depsRequiredByJobs, genJobEx
import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson, getAliasedJsImportStmtAndIdentifier)
import Wasp.Generator.ServerGenerator.OperationsG (genOperations)
import Wasp.Generator.ServerGenerator.OperationsRoutesG (genOperationsRoutes)
import Wasp.Generator.ServerGenerator.WebSocketG (depsRequiredByWebSockets, genWebSockets, mkWebSocketFnImport)
import qualified Wasp.Node.Version as NodeVersion
import Wasp.Project.Db (databaseUrlEnvVarName)
import Wasp.SemanticVersion (major)
@ -166,7 +167,8 @@ npmDepsForWasp spec =
]
++ depsRequiredByPassport spec
++ depsRequiredByJobs spec
++ depsRequiredByEmail spec,
++ depsRequiredByEmail spec
++ depsRequiredByWebSockets spec,
N.waspDevDependencies =
AS.Dependency.fromList
[ ("nodemon", "^2.0.19"),
@ -178,7 +180,9 @@ npmDepsForWasp spec =
("@types/express", "^4.17.13"),
("@types/express-serve-static-core", "^4.17.13"),
("@types/node", "^18.11.9"),
("@tsconfig/node" ++ show (major NodeVersion.latestMajorNodeVersion), "^1.0.1")
("@tsconfig/node" ++ show (major NodeVersion.latestMajorNodeVersion), "^1.0.1"),
("@types/uuid", "^9.0.0"),
("@types/cors", "^2.8.5")
]
}
@ -217,6 +221,7 @@ genSrcDir spec =
<++> genEmailSender spec
<++> genDbSeed spec
<++> genMiddleware spec
<++> genWebSockets spec
where
genFileCopy = return . C.mkSrcTmplFd
@ -247,14 +252,16 @@ genServerJs spec =
( Just $
object
[ "setupFn" .= extImportToImportJson relPathToServerSrcDir maybeSetupJsFunction,
"isPgBossJobExecutorUsed" .= isPgBossJobExecutorUsed spec
"isPgBossJobExecutorUsed" .= isPgBossJobExecutorUsed spec,
"userWebSocketFn" .= mkWebSocketFnImport maybeWebSocket [reldirP|./|]
]
)
where
maybeSetupJsFunction = AS.App.Server.setupFn =<< AS.App.server (snd $ getApp spec)
maybeWebSocket = AS.App.webSocket $ snd $ getApp spec
relPathToServerSrcDir :: Path Posix (Rel importLocation) (Dir C.ServerSrcDir)
relPathToServerSrcDir = [reldirP|./|]
relPathToServerSrcDir :: Path Posix (Rel importLocation) (Dir C.ServerSrcDir)
relPathToServerSrcDir = [reldirP|./|]
genRoutesDir :: AppSpec -> Generator [FileDraft]
genRoutesDir spec =

View File

@ -0,0 +1,84 @@
module Wasp.Generator.ServerGenerator.WebSocketG
( depsRequiredByWebSockets,
genWebSockets,
genWebSocketIndex,
mkWebSocketFnImport,
)
where
import Data.Aeson (object, (.=))
import qualified Data.Aeson as Aeson
import StrongPath
( Dir,
Path,
Posix,
Rel,
reldirP,
relfile,
)
import Wasp.AppSpec (AppSpec)
import qualified Wasp.AppSpec as AS
import qualified Wasp.AppSpec.App as AS.App
import qualified Wasp.AppSpec.App.Dependency as AS.Dependency
import Wasp.AppSpec.App.WebSocket (WebSocket)
import qualified Wasp.AppSpec.App.WebSocket as AS.App.WS
import Wasp.AppSpec.Valid (getApp, isAuthEnabled)
import Wasp.Generator.Common
( makeJsonWithEntityData,
)
import Wasp.Generator.FileDraft (FileDraft)
import Wasp.Generator.Monad (Generator)
import qualified Wasp.Generator.ServerGenerator.Common as C
import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson)
import qualified Wasp.Generator.WebSocket as AS.WS
depsRequiredByWebSockets :: AppSpec -> [AS.Dependency.Dependency]
depsRequiredByWebSockets spec
| AS.WS.areWebSocketsUsed spec = AS.WS.serverDepsRequiredForWebSockets
| otherwise = []
genWebSockets :: AppSpec -> Generator [FileDraft]
genWebSockets spec
| AS.WS.areWebSocketsUsed spec =
sequence
[ genWebSocketIndex spec,
genWebSocketInitialization spec
]
| otherwise = return []
genWebSocketIndex :: AppSpec -> Generator FileDraft
genWebSocketIndex spec =
return $
C.mkTmplFdWithDstAndData
(C.asTmplFile [relfile|src/webSocket/index.ts|])
(C.asServerFile [relfile|src/webSocket/index.ts|])
( Just $
object
[ "isAuthEnabled" .= isAuthEnabled spec,
"userWebSocketFn" .= mkWebSocketFnImport maybeWebSocket [reldirP|../|],
"allEntities" .= map (makeJsonWithEntityData . fst) (AS.getEntities spec)
]
)
where
maybeWebSocket = AS.App.webSocket $ snd $ getApp spec
genWebSocketInitialization :: AppSpec -> Generator FileDraft
genWebSocketInitialization spec =
return $
C.mkTmplFdWithDstAndData
(C.asTmplFile [relfile|src/webSocket/initialization.ts|])
(C.asServerFile [relfile|src/webSocket/initialization.ts|])
( Just $
object
[ "isAuthEnabled" .= isAuthEnabled spec,
"userWebSocketFn" .= mkWebSocketFnImport maybeWebSocket [reldirP|../|],
"allEntities" .= map (makeJsonWithEntityData . fst) (AS.getEntities spec)
]
)
where
maybeWebSocket = AS.App.webSocket $ snd $ getApp spec
mkWebSocketFnImport :: Maybe WebSocket -> Path Posix (Rel importLocation) (Dir C.ServerSrcDir) -> Aeson.Value
mkWebSocketFnImport maybeWebSocket relPathToServerSrcDir = extImportToImportJson relPathToServerSrcDir maybeWebSocketFn
where
maybeWebSocketFn = AS.App.WS.fn <$> maybeWebSocket

View File

@ -7,6 +7,7 @@ module Wasp.Generator.WebAppGenerator
where
import Data.Aeson (object, (.=))
import Data.Char (toLower)
import Data.List (intercalate)
import StrongPath
( Dir,
@ -21,9 +22,11 @@ import StrongPath
)
import Wasp.AppSpec (AppSpec)
import qualified Wasp.AppSpec as AS
import Wasp.AppSpec.App (App (webSocket))
import qualified Wasp.AppSpec.App as AS.App
import qualified Wasp.AppSpec.App.Client as AS.App.Client
import qualified Wasp.AppSpec.App.Dependency as AS.Dependency
import Wasp.AppSpec.App.WebSocket (WebSocket (..))
import qualified Wasp.AppSpec.Entity as AS.Entity
import Wasp.AppSpec.Valid (getApp, isAuthEnabled)
import Wasp.Env (envVarsToDotEnvContent)
@ -47,6 +50,7 @@ import Wasp.Generator.WebAppGenerator.ExternalCodeGenerator
import Wasp.Generator.WebAppGenerator.JsImport (extImportToImportJson)
import Wasp.Generator.WebAppGenerator.OperationsGenerator (genOperations)
import Wasp.Generator.WebAppGenerator.RouterGenerator (genRouter)
import qualified Wasp.Generator.WebSocket as AS.WS
import qualified Wasp.Node.Version as NodeVersion
import qualified Wasp.SemanticVersion as SV
import Wasp.Util ((<++>))
@ -131,10 +135,12 @@ npmDepsForWasp spec =
-- CLI to generate what's necessary, check the description in
-- https://github.com/wasp-lang/wasp/pull/962/ for details).
("@prisma/client", show prismaVersion),
("superjson", "^1.12.2")
("superjson", "^1.12.2"),
("mitt", "3.0.0")
]
++ depsRequiredForAuth spec
++ depsRequiredByTailwind spec,
++ depsRequiredByTailwind spec
++ depsRequiredForWebSockets spec,
N.waspDevDependencies =
AS.Dependency.fromList
[ -- TODO: Allow users to choose whether they want to use TypeScript
@ -181,6 +187,11 @@ depsRequiredForTesting =
("msw", "^1.1.0")
]
depsRequiredForWebSockets :: AppSpec -> [AS.Dependency.Dependency]
depsRequiredForWebSockets spec
| AS.WS.areWebSocketsUsed spec = AS.WS.clientDepsRequiredForWebSockets
| otherwise = []
genGitignore :: Generator FileDraft
genGitignore =
return $
@ -237,13 +248,15 @@ genSrcDir spec =
genFileCopy [relfile|vite-env.d.ts|],
-- Generates api.js file which contains token management and configured api (e.g. axios) instance.
genFileCopy [relfile|api.ts|],
genFileCopy [relfile|api/events.ts|],
genFileCopy [relfile|storage.ts|],
genRouter spec,
genIndexJs spec
getIndexTs spec
]
<++> genOperations spec
<++> genEntitiesDir spec
<++> genAuth spec
<++> genWebSockets spec
where
genFileCopy = return . C.mkSrcTmplFd
@ -257,15 +270,16 @@ genEntitiesDir spec = return [entitiesIndexFileDraft]
(Just $ object ["entities" .= allEntities])
allEntities = map (makeJsonWithEntityData . fst) $ AS.getDecls @AS.Entity.Entity spec
genIndexJs :: AppSpec -> Generator FileDraft
genIndexJs spec =
getIndexTs :: AppSpec -> Generator FileDraft
getIndexTs spec =
return $
C.mkTmplFdWithDstAndData
(C.asTmplFile [relfile|src/index.tsx|])
(C.asWebAppFile [relfile|src/index.tsx|])
( Just $
object
[ "setupFn" .= extImportToImportJson relPathToWebAppSrcDir maybeSetupJsFunction
[ "setupFn" .= extImportToImportJson relPathToWebAppSrcDir maybeSetupJsFunction,
"areWebSocketsUsed" .= AS.WS.areWebSocketsUsed spec
]
)
where
@ -287,3 +301,22 @@ genEnvValidationScript =
[ C.mkTmplFd [relfile|scripts/validate-env.mjs|],
C.mkUniversalTmplFdWithDst [relfile|validators.js|] [relfile|scripts/universal/validators.mjs|]
]
genWebSockets :: AppSpec -> Generator [FileDraft]
genWebSockets spec
| AS.WS.areWebSocketsUsed spec =
sequence
[ genFileCopy [relfile|webSocket.ts|],
genWebSocketProvider spec
]
| otherwise = return []
where
genFileCopy = return . C.mkSrcTmplFd
genWebSocketProvider :: AppSpec -> Generator FileDraft
genWebSocketProvider spec = return $ C.mkTmplFdWithData tmplFile tmplData
where
maybeWebSocket = webSocket $ snd $ getApp spec
shouldAutoConnect = (autoConnect <$> maybeWebSocket) /= Just (Just False)
tmplData = object ["autoConnect" .= map toLower (show shouldAutoConnect)]
tmplFile = C.asTmplFile [relfile|src/webSocket/WebSocketProvider.tsx|]

View File

@ -0,0 +1,34 @@
module Wasp.Generator.WebSocket
( areWebSocketsUsed,
serverDepsRequiredForWebSockets,
clientDepsRequiredForWebSockets,
)
where
import Data.Maybe (isJust)
import Wasp.AppSpec (AppSpec (..))
import qualified Wasp.AppSpec.App as AS.App
import qualified Wasp.AppSpec.App.Dependency as AS.Dependency
import Wasp.AppSpec.Valid (getApp)
import qualified Wasp.SemanticVersion as SV
areWebSocketsUsed :: AppSpec -> Bool
areWebSocketsUsed spec = isJust $ AS.App.webSocket $ snd $ getApp spec
socketIoVersionRange :: SV.Range
socketIoVersionRange = SV.Range [SV.backwardsCompatibleWith (SV.Version 4 6 1)]
socketIoComponentEmitterVersionRange :: SV.Range
socketIoComponentEmitterVersionRange = SV.Range [SV.backwardsCompatibleWith (SV.Version 4 0 0)]
serverDepsRequiredForWebSockets :: [AS.Dependency.Dependency]
serverDepsRequiredForWebSockets =
[ AS.Dependency.make ("socket.io", show socketIoVersionRange),
AS.Dependency.make ("@socket.io/component-emitter", show socketIoComponentEmitterVersionRange)
]
clientDepsRequiredForWebSockets :: [AS.Dependency.Dependency]
clientDepsRequiredForWebSockets =
[ AS.Dependency.make ("socket.io-client", show socketIoVersionRange),
AS.Dependency.make ("@socket.io/component-emitter", show socketIoComponentEmitterVersionRange)
]

View File

@ -186,7 +186,8 @@ spec_Analyzer = do
{ EmailSender.email = "test@test.com",
EmailSender.name = Just "Test"
}
}
},
App.webSocket = Nothing
}
)
]

View File

@ -291,7 +291,8 @@ spec_AppSpecValid = do
AS.App.auth = Nothing,
AS.App.dependencies = Nothing,
AS.App.head = Nothing,
AS.App.emailSender = Nothing
AS.App.emailSender = Nothing,
AS.App.webSocket = Nothing
}
basicAppDecl = AS.Decl.makeDecl "TestApp" basicApp

View File

@ -41,7 +41,8 @@ spec_WebAppGenerator = do
AS.App.auth = Nothing,
AS.App.dependencies = Nothing,
AS.App.head = Nothing,
AS.App.emailSender = Nothing
AS.App.emailSender = Nothing,
AS.App.webSocket = Nothing
}
],
AS.waspProjectDir = systemSPRoot SP.</> [SP.reldir|test/|],

View File

@ -198,6 +198,7 @@ library
Wasp.AppSpec.App.Dependency
Wasp.AppSpec.App.Server
Wasp.AppSpec.App.Wasp
Wasp.AppSpec.App.WebSocket
Wasp.AppSpec.ConfigFile
Wasp.AppSpec.Core.Decl
Wasp.AppSpec.Core.Ref
@ -273,6 +274,7 @@ library
Wasp.Generator.ServerGenerator.OperationsRoutesG
Wasp.Generator.ServerGenerator.Setup
Wasp.Generator.ServerGenerator.Start
Wasp.Generator.ServerGenerator.WebSocketG
Wasp.Generator.ServerGenerator.CrudG
Wasp.Generator.Setup
Wasp.Generator.Start
@ -294,6 +296,7 @@ library
Wasp.Generator.WebAppGenerator.Setup
Wasp.Generator.WebAppGenerator.Start
Wasp.Generator.WebAppGenerator.Test
Wasp.Generator.WebSocket
Wasp.Generator.WebAppGenerator.CrudG
Wasp.Generator.WriteFileDrafts
Wasp.Node.Version

View File

@ -59,7 +59,7 @@ app todoApp {
}
```
```ts title=src/server/serverSetup.js
```ts title=src/server/serverSetup.ts
import cors from 'cors'
import { MiddlewareConfigFn } from '@wasp/middleware'
import config from '@wasp/config.js'

View File

@ -0,0 +1,167 @@
---
title: WebSockets
---
import useBaseUrl from '@docusaurus/useBaseUrl';
# WebSocket support
Wasp provides a fully integrated WebSocket experience by utilizing [Socket.IO](https://socket.io/) on the client and server.
We handle making sure your URLs are correctly setup, CORS is enabled, and provide a useful `useSocket` and `useSocketListener` abstractions for use in React components.
To get started, you need to:
1. Define your WebSocket logic on the server.
2. Declare you are using WebSockets in your Wasp file, and connect it with your server logic.
3. Use WebSockets on the client, in React, via `useSocket` and `useSocketListener`.
4. Optionally, type the WebSocket events and payloads for full-stack type safety.
We will cover all the steps above, but in an order that makes it easier to explain new concepts.
## Turn on WebSockets in your Wasp file
We specify that we are using WebSockets by adding `webSocket` to our `app` and providing the required `fn`. You can optionally change the auto-connect behavior (on by default).
```wasp title=todoApp.wasp
app todoApp {
// ...
webSocket: {
fn: import { webSocketFn } from "@server/webSocket.js",
autoConnect: true, // optional, default: true
},
}
```
## Implement the WebSocket server
Let's define the server with all of the events and handler functions.
:::info Full-stack type safety
If you are using TypeScript, you can define event names with the matching payload types on the server and have those types exposed automatically on the client. This helps you avoid mistakes when emitting events or handling them. Read more in the [Typescript guide](/docs/typescript#websocket-full-stack-type-support).
:::
### Defining the events handler
On the server, you will get Socket.IO `io: Server` argument and `context` for your WebSocket function, which contains all entities you defined in your Wasp app. You can use this `io` object to register callbacks for all the regular [Socket.IO events](https://socket.io/docs/v4/server-api/).
Lastly, if a user is logged in, you will have a `socket.data.user` on the server.
```ts title=src/server/webSocket.ts
import type { WebSocketDefinition, WaspSocketData } from '@wasp/webSocket'
import { v4 as uuidv4 } from 'uuid'
export const webSocketFn: WebSocketFn = (io, context) => {
io.on('connection', (socket) => {
const username = socket.data.user?.email || socket.data.user?.username || 'unknown'
console.log('a user connected: ', username)
socket.on('chatMessage', async (msg) => {
console.log('message: ', msg)
io.emit('chatMessage', { id: uuidv4(), username, text: msg })
// You can also use your entities here:
// await context.entities.SomeEntity.create({ someField: msg })
})
})
}
// Typing our WebSocket function with the events and payloads
// allows us to get type safety on the client as well
type WebSocketFn = WebSocketDefinition<
ClientToServerEvents,
ServerToClientEvents,
InterServerEvents,
SocketData
>
interface ServerToClientEvents {
chatMessage: (msg: { id: string, username: string, text: string }) => void;
}
interface ClientToServerEvents {
chatMessage: (msg: string) => void;
}
interface InterServerEvents {}
// Data that is attached to the socket.
// NOTE: Wasp automatically injects the JWT into the connection,
// and if present/valid, the server adds a user to the socket.
interface SocketData extends WaspSocketData {}
```
## Using the WebSocket on the client
Client access to WebSockets is provided by the `useSocket` hook. It returns:
- `socket: Socket` for sending and receiving events.
- `isConnected: boolean` for showing a display of the Socket.IO connection status.
- Note: Wasp automatically connects and establishes a WebSocket connection from the client to the server by default, so you do not need to explicitly `socket.connect()` or `socket.disconnect()`.
- If you set `autoConnect: false` in your Wasp file, then you should call these as needed.
Additionally, there is a `useSocketListener: (event, callback) => void` hook which is used for registering event handlers. It takes care of unregistering on unmount.
All components using `useSocket` share the same underlying `socket`.
```tsx title=src/client/ChatPage.tsx
import React, { useState } from 'react'
import {
useSocket,
useSocketListener,
ServerToClientPayload,
} from '@wasp/webSocket'
export const ChatPage = () => {
const [messageText, setMessageText] = useState<
// We are using a helper type to get the payload type for the "chatMessage" event.
ClientToServerPayload<'chatMessage'>
>('')
const [messages, setMessages] = useState<
ServerToClientPayload<'chatMessage'>[]
>([])
// The "socket" instance is typed with the types you defined on the server.
const { socket, isConnected } = useSocket()
// This is a type-safe event handler: "chatMessage" event and its payload type
// are defined on the server.
useSocketListener('chatMessage', logMessage)
function logMessage(msg: ServerToClientPayload<'chatMessage'>) {
setMessages((priorMessages) => [msg, ...priorMessages])
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
// This is a type-safe event emitter: "chatMessage" event and its payload type
// are defined on the server.
socket.emit('chatMessage', messageText)
setMessageText('')
}
const messageList = messages.map((msg) => (
<li key={msg.id}>
<em>{msg.username}</em>: {msg.text}
</li>
))
const connectionIcon = isConnected ? '🟢' : '🔴'
return (
<>
<h2>Chat {connectionIcon}</h2>
<div>
<form onSubmit={handleSubmit}>
<div>
<div>
<input
type="text"
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
/>
</div>
<div>
<button type="submit">Submit</button>
</div>
</div>
</form>
<ul>{messageList}</ul>
</div>
</>
)
}
```
Read more about the types that are available when using WebSockets with Typescript in the [Typescript guide](/docs/typescript#websocket-full-stack-type-support).

View File

@ -63,6 +63,10 @@ Check [`app.dependencies`](/docs/language/features#dependencies) for more detail
Email sender configuration.
Check [`app.emailSender`](/docs/language/features#email-sender) for more details.
#### `webSocket: dict` (optional)
WebSocket configuration.
Check out the [`WebSocket guide`](/docs/guides/websockets) for more details.
## Page
`page` declaration is the top-level layout abstraction. Your app can have multiple pages.
@ -2076,7 +2080,7 @@ app MyApp {
}
```
`app.server` is a dictionary with following fields:
`app.server` is a dictionary with the following fields:
#### `middlewareConfigFn: ServerImport` (optional)
@ -2086,7 +2090,7 @@ The import statement to an Express middleware config function. This is a global
`setupFn` declares a JS function that will be executed on server start. This function is expected to be async and will be awaited before the server starts accepting any requests.
It gives you an opportunity to do any custom setup, e.g. setting up additional database/websockets or starting cron/scheduled jobs.
It allows you to do any custom setup, e.g. setting up additional database/websockets or starting cron/scheduled jobs.
The `setupFn` function receives the `express.Application` and the `http.Server` instances as part of its context. They can be useful for setting up any custom server routes or for example, setting up `socket.io`.
```ts

View File

@ -418,4 +418,100 @@ import type { GetAllQuery, GetQuery, CreateAction, UpdateAction, DeleteAction }
export const getAllOverride: GetAllQuery<Input, Output> = async (args, context) => {
// ...
}
```
## WebSocket full-stack type support
Defining event names with the matching payload types on the server makes those types exposed automatically on the client. This helps you avoid mistakes when emitting events or handling them.
### Defining the events handler
On the server, you will get Socket.IO `io: Server` argument and `context` for your WebSocket function, which contains all entities you defined in your Wasp app. You can type the `webSocketFn` function like this:
```ts title=src/server/webSocket.ts
import type { WebSocketDefinition, WaspSocketData } from '@wasp/webSocket'
// Using the generic WebSocketDefinition type to define the WebSocket function.
type WebSocketFn = WebSocketDefinition<
ClientToServerEvents,
ServerToClientEvents,
InterServerEvents,
SocketData
>
interface ServerToClientEvents {
// The type for the payload of the "chatMessage" event.
chatMessage: (msg: { id: string, username: string, text: string }) => void;
}
interface ClientToServerEvents {
// The type for the payload of the "chatMessage" event.
chatMessage: (msg: string) => void;
}
interface InterServerEvents {}
interface SocketData extends WaspSocketData {}
// Use the WebSocketFn to type the webSocketFn function.
export const webSocketFn: WebSocketFn = (io, context) => {
io.on('connection', (socket) => {
socket.on('chatMessage', async (msg) => {
io.emit('chatMessage', { ... })
})
})
}
```
### Using the WebSocket on the client
After you have defined the WebSocket function on the server, you can use it on the client. The `useSocket` hook will give you the `socket` instance and the `isConnected` boolean. The `socket` instance is typed with the types you defined on the server.
The `useSocketListener` hook will give you a type-safe event handler. The event name and its payload type are defined on the server.
You can additonally use the `ClientToServerPayload` and `ServerToClientPayload` helper types to get the payload type for a specific event.
```tsx title=src/client/ChatPage.tsx
import React, { useState } from 'react'
import {
useSocket,
useSocketListener,
ServerToClientPayload,
ClientToServerPayload,
} from '@wasp/webSocket'
export const ChatPage = () => {
const [messageText, setMessageText] = useState<
// We are using a helper type to get the payload type for the "chatMessage" event.
ClientToServerPayload<'chatMessage'>
>('')
const [messages, setMessages] = useState<
// We are using a helper type to get the payload type for the "chatMessage" event.
ServerToClientPayload<'chatMessage'>[]
>([])
// The "socket" instance is typed with the types you defined on the server.
const { socket, isConnected } = useSocket()
// This is a type-safe event handler: "chatMessage" event and its payload type
// are defined on the server.
useSocketListener('chatMessage', logMessage)
function logMessage(msg: ServerToClientPayload<'chatMessage'>) {
// ...
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
// This is a type-safe event emitter: "chatMessage" event and its payload type
// are defined on the server.
socket.emit('chatMessage', messageText)
setMessageText('')
}
return (
...
)
}
```

View File

@ -63,6 +63,7 @@ module.exports = {
"guides/sending-emails",
"guides/middleware-customization",
"guides/crud",
"guides/websockets",
],
},
{