Implement Wasp SDK generation (#1626)

This commit is contained in:
Filip Sodić 2024-01-29 16:08:15 +01:00 committed by GitHub
parent bc9e1b6bce
commit cbd7310b16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
308 changed files with 9281 additions and 3892 deletions

View File

@ -22,13 +22,7 @@ import qualified Wasp.Generator
import Wasp.Generator.Monad (GeneratorWarning (GeneratorNeedsMigrationWarning)) import Wasp.Generator.Monad (GeneratorWarning (GeneratorNeedsMigrationWarning))
import qualified Wasp.Message as Msg import qualified Wasp.Message as Msg
import Wasp.Project (CompileError, CompileWarning, WaspProjectDir) import Wasp.Project (CompileError, CompileWarning, WaspProjectDir)
import Wasp.Project.Common import Wasp.Project.Common (buildDirInDotWaspDir, dotWaspDirInWaspProjectDir)
( buildDirInDotWaspDir,
dotWaspDirInWaspProjectDir,
extClientCodeDirInWaspProjectDir,
extServerCodeDirInWaspProjectDir,
extSharedCodeDirInWaspProjectDir,
)
-- | Builds Wasp project that the current working directory is part of. -- | Builds Wasp project that the current working directory is part of.
-- Does all the steps, from analysis to generation, and at the end writes generated code -- Does all the steps, from analysis to generation, and at the end writes generated code
@ -71,9 +65,7 @@ buildIO waspProjectDir buildDir = compileIOWithOptions options waspProjectDir bu
where where
options = options =
CompileOptions CompileOptions
{ externalClientCodeDirPath = waspProjectDir </> extClientCodeDirInWaspProjectDir, { waspProjectDirPath = waspProjectDir,
externalServerCodeDirPath = waspProjectDir </> extServerCodeDirInWaspProjectDir,
externalSharedCodeDirPath = waspProjectDir </> extSharedCodeDirInWaspProjectDir,
isBuild = True, isBuild = True,
sendMessage = cliSendMessage, sendMessage = cliSendMessage,
-- Ignore "DB needs migration warnings" during build, as that is not a required step. -- Ignore "DB needs migration warnings" during build, as that is not a required step.

View File

@ -26,7 +26,7 @@ import qualified Wasp.Generator
import qualified Wasp.Message as Msg import qualified Wasp.Message as Msg
import Wasp.Project (CompileError, CompileWarning, WaspProjectDir) import Wasp.Project (CompileError, CompileWarning, WaspProjectDir)
import qualified Wasp.Project import qualified Wasp.Project
import Wasp.Project.Common (dotWaspDirInWaspProjectDir, extClientCodeDirInWaspProjectDir, extServerCodeDirInWaspProjectDir, extSharedCodeDirInWaspProjectDir, generatedCodeDirInDotWaspDir) import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir)
-- | Same like 'compileWithOptions', but with default compile options. -- | Same like 'compileWithOptions', but with default compile options.
compile :: Command [CompileWarning] compile :: Command [CompileWarning]
@ -115,9 +115,7 @@ compileIOWithOptions options waspProjectDir outDir =
defaultCompileOptions :: Path' Abs (Dir WaspProjectDir) -> CompileOptions defaultCompileOptions :: Path' Abs (Dir WaspProjectDir) -> CompileOptions
defaultCompileOptions waspProjectDir = defaultCompileOptions waspProjectDir =
CompileOptions CompileOptions
{ externalServerCodeDirPath = waspProjectDir </> extServerCodeDirInWaspProjectDir, { waspProjectDirPath = waspProjectDir,
externalClientCodeDirPath = waspProjectDir </> extClientCodeDirInWaspProjectDir,
externalSharedCodeDirPath = waspProjectDir </> extSharedCodeDirInWaspProjectDir,
isBuild = False, isBuild = False,
sendMessage = cliSendMessage, sendMessage = cliSendMessage,
generatorWarningsFilter = id generatorWarningsFilter = id

View File

@ -15,9 +15,12 @@ import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require) import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require)
import Wasp.Cli.Command.Watch (watch) import Wasp.Cli.Command.Watch (watch)
import qualified Wasp.Generator import qualified Wasp.Generator
import Wasp.Generator.Common (ProjectRootDir)
import qualified Wasp.Message as Msg import qualified Wasp.Message as Msg
import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir) import Wasp.Project.Common
( WaspProjectDir,
dotWaspDirInWaspProjectDir,
generatedCodeDirInDotWaspDir,
)
test :: [String] -> Command () test :: [String] -> Command ()
test [] = throwError $ CommandError "Not enough arguments" "Expected: wasp test client <args>" test [] = throwError $ CommandError "Not enough arguments" "Expected: wasp test client <args>"
@ -25,7 +28,7 @@ test ("client" : args) = watchAndTest $ Wasp.Generator.testWebApp args
test ("server" : _args) = throwError $ CommandError "Invalid arguments" "Server testing not yet implemented." test ("server" : _args) = throwError $ CommandError "Invalid arguments" "Server testing not yet implemented."
test _ = throwError $ CommandError "Invalid arguments" "Expected: wasp test client <args>" test _ = throwError $ CommandError "Invalid arguments" "Expected: wasp test client <args>"
watchAndTest :: (Path' Abs (Dir ProjectRootDir) -> IO (Either String ())) -> Command () watchAndTest :: (Path' Abs (Dir WaspProjectDir) -> IO (Either String ())) -> Command ()
watchAndTest testRunner = do watchAndTest testRunner = do
InWaspProject waspRoot <- require InWaspProject waspRoot <- require
let outDir = waspRoot </> dotWaspDirInWaspProjectDir </> generatedCodeDirInDotWaspDir let outDir = waspRoot </> dotWaspDirInWaspProjectDir </> generatedCodeDirInDotWaspDir
@ -39,7 +42,7 @@ watchAndTest testRunner = do
watchOrStartResult <- liftIO $ do watchOrStartResult <- liftIO $ do
ongoingCompilationResultMVar <- newMVar (warnings, []) ongoingCompilationResultMVar <- newMVar (warnings, [])
let watchWaspProjectSource = watch waspRoot outDir ongoingCompilationResultMVar let watchWaspProjectSource = watch waspRoot outDir ongoingCompilationResultMVar
watchWaspProjectSource `race` testRunner outDir watchWaspProjectSource `race` testRunner waspRoot
case watchOrStartResult of case watchOrStartResult of
Left () -> error "This should never happen, listening for file changes should never end but it did." Left () -> error "This should never happen, listening for file changes should never end but it did."

View File

@ -19,7 +19,7 @@ import Wasp.Cli.Message (cliSendMessage)
import qualified Wasp.Generator.Common as Wasp.Generator import qualified Wasp.Generator.Common as Wasp.Generator
import qualified Wasp.Message as Msg import qualified Wasp.Message as Msg
import Wasp.Project (CompileError, CompileWarning, WaspProjectDir) import Wasp.Project (CompileError, CompileWarning, WaspProjectDir)
import Wasp.Project.Common (srcDirInWaspProjectDir) import Wasp.Project.Common (extPublicDirInWaspProjectDir, srcDirInWaspProjectDir)
-- TODO: Idea: Read .gitignore file, and ignore everything from it. This will then also cover the -- TODO: Idea: Read .gitignore file, and ignore everything from it. This will then also cover the
-- .wasp dir, and users can easily add any custom stuff they want ignored. But, we also have to -- .wasp dir, and users can easily add any custom stuff they want ignored. But, we also have to
@ -39,7 +39,8 @@ watch ::
watch waspProjectDir outDir ongoingCompilationResultMVar = FSN.withManager $ \mgr -> do watch waspProjectDir outDir ongoingCompilationResultMVar = FSN.withManager $ \mgr -> do
chan <- newChan chan <- newChan
_ <- watchFilesAtTopLevelOfWaspProjectDir mgr chan _ <- watchFilesAtTopLevelOfWaspProjectDir mgr chan
_ <- watchFilesAtAllLevelsOfSrcDirInWaspProjectDir mgr chan _ <- watchFilesAtAllLevelsOfDirInWaspProjectDir mgr chan srcDirInWaspProjectDir
_ <- watchFilesAtAllLevelsOfDirInWaspProjectDir mgr chan extPublicDirInWaspProjectDir
listenForEvents chan =<< getCurrentTime listenForEvents chan =<< getCurrentTime
where where
watchFilesAtTopLevelOfWaspProjectDir mgr chan = watchFilesAtTopLevelOfWaspProjectDir mgr chan =
@ -53,8 +54,8 @@ watch waspProjectDir outDir ongoingCompilationResultMVar = FSN.withManager $ \mg
where where
filename = FP.takeFileName $ FSN.eventPath event filename = FP.takeFileName $ FSN.eventPath event
watchFilesAtAllLevelsOfSrcDirInWaspProjectDir mgr chan = watchFilesAtAllLevelsOfDirInWaspProjectDir mgr chan dirInWaspProjectDir =
FSN.watchTreeChan mgr (SP.fromAbsDir $ waspProjectDir </> srcDirInWaspProjectDir) eventFilter chan FSN.watchTreeChan mgr (SP.fromAbsDir $ waspProjectDir </> dirInWaspProjectDir) eventFilter chan
where where
eventFilter :: FSN.Event -> Bool eventFilter :: FSN.Event -> Bool
eventFilter event = eventFilter event =

View File

@ -5,6 +5,8 @@
"react": "^18.2.0" "react": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.1.0",
"vite": "^4.3.9",
"@types/react": "^18.0.37", "@types/react": "^18.0.37",
"prisma": "4.16.2" "prisma": "4.16.2"
}, },

View File

@ -1,4 +1,4 @@
import { throwIfNotValidAbsoluteURL } from './universal/validators.mjs'; import { throwIfNotValidAbsoluteURL } from 'wasp/universal/validators';
console.info("🔍 Validating environment variables..."); console.info("🔍 Validating environment variables...");
throwIfNotValidAbsoluteURL(process.env.REACT_APP_API_URL, 'Environemnt variable REACT_APP_API_URL'); throwIfNotValidAbsoluteURL(process.env.REACT_APP_API_URL, 'Environemnt variable REACT_APP_API_URL');

View File

@ -1,10 +0,0 @@
{{={= =}=}}
import { createAction } from './core'
{=& operationTypeImportStmt =}
const action = createAction<{= operationTypeName =}>(
'{= actionRoute =}',
{=& entitiesArray =},
)
export default action

View File

@ -1,13 +0,0 @@
import { type Action } from '.'
import type { Expand, _Awaited, _ReturnType } from '../universal/types'
export function createAction<BackendAction extends GenericBackendAction>(
actionRoute: string,
entitiesUsed: unknown[]
): ActionFor<BackendAction>
type ActionFor<BackendAction extends GenericBackendAction> = Expand<
Action<Parameters<BackendAction>[0], _Awaited<_ReturnType<BackendAction>>>
>
type GenericBackendAction = (args: never, context: any) => unknown

View File

@ -1,35 +0,0 @@
import { callOperation, makeOperationRoute } from '../operations'
import {
registerActionInProgress,
registerActionDone,
} from '../operations/resources'
export function createAction(relativeActionRoute, entitiesUsed) {
const actionRoute = makeOperationRoute(relativeActionRoute)
async function internalAction(args, specificOptimisticUpdateDefinitions) {
registerActionInProgress(specificOptimisticUpdateDefinitions)
try {
// The `return await` is not redundant here. If we removed the await, the
// `finally` block would execute before the action finishes, prematurely
// registering the action as done.
return await callOperation(actionRoute, args)
} finally {
await registerActionDone(entitiesUsed, specificOptimisticUpdateDefinitions)
}
}
// We expose (and document) a restricted version of the API for our users,
// while also attaching the full "internal" API to the exposed action. By
// doing this, we can easily use the internal API of an action a users passes
// into our system (e.g., through the `useAction` hook) without needing a
// lookup table.
//
// While it does technically allow our users to access the interal API, it
// shouldn't be a problem in practice. Still, if it turns out to be a problem,
// we can always hide it using a Symbol.
const action = (args) => internalAction(args, [])
action.internal = internalAction
return action
}

View File

@ -1,269 +0,0 @@
import {
QueryClient,
QueryKey,
useMutation,
UseMutationOptions,
useQueryClient,
} from '@tanstack/react-query'
import { type Query } from '../queries';
export type Action<Input, Output> =
[Input] extends [never] ?
(args?: unknown) => Promise<Output> :
(args: Input) => Promise<Output>
/**
* An options object passed into the `useAction` hook and used to enhance the
* action with extra options.
*
*/
export type ActionOptions<ActionInput> = {
optimisticUpdates: OptimisticUpdateDefinition<ActionInput, any>[]
}
/**
* A documented (public) way to define optimistic updates.
*/
export type OptimisticUpdateDefinition<ActionInput, CachedData> = {
getQuerySpecifier: GetQuerySpecifier<ActionInput, CachedData>
updateQuery: UpdateQuery<ActionInput, CachedData>
}
/**
* A function that takes an item and returns a Wasp Query specifier.
*/
export type GetQuerySpecifier<ActionInput, CachedData> = (item: ActionInput) => QuerySpecifier<unknown, CachedData>
/**
* A function that takes an item and the previous state of the cache, and returns
* the desired (new) state of the cache.
*/
export type UpdateQuery<ActionInput, CachedData> = (item: ActionInput, oldData: CachedData | undefined) => CachedData
/**
* A public query specifier used for addressing Wasp queries. See our docs for details:
* https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates
*/
export type QuerySpecifier<Input, Output> = [Query<Input, Output>, ...any[]]
/**
* A hook for adding extra behavior to a Wasp Action (e.g., optimistic updates).
*
* @param actionFn The Wasp Action you wish to enhance/decorate.
* @param actionOptions An options object for enhancing/decorating the given Action.
* @returns A decorated Action with added behavior but an unchanged API.
*/
export function useAction<Input = unknown, Output = unknown>(
actionFn: Action<Input, Output>,
actionOptions?: ActionOptions<Input>
): typeof actionFn {
const queryClient = useQueryClient();
let mutationFn = actionFn
let options = {}
if (actionOptions?.optimisticUpdates) {
const optimisticUpdatesDefinitions = actionOptions.optimisticUpdates.map(translateToInternalDefinition)
mutationFn = makeOptimisticUpdateMutationFn(actionFn, optimisticUpdatesDefinitions)
options = makeRqOptimisticUpdateOptions(queryClient, optimisticUpdatesDefinitions)
}
// NOTE: We decided to hide React Query's extra mutation features (e.g.,
// isLoading, onSuccess and onError callbacks, synchronous mutate) and only
// expose a simple async function whose API matches the original Action.
// We did this to avoid cluttering the API with stuff we're not sure we need
// yet (e.g., isLoading), to postpone the action vs mutation dilemma, and to
// clearly separate our opinionated API from React Query's lower-level
// advanced API (which users can also use)
const mutation = useMutation(mutationFn, options)
return (args) => mutation.mutateAsync(args)
}
/**
* An internal (undocumented, private, desugared) way of defining optimistic updates.
*/
type InternalOptimisticUpdateDefinition<ActionInput, CachedData> = {
getQueryKey: (item: ActionInput) => QueryKey,
updateQuery: UpdateQuery<ActionInput, CachedData>;
}
/**
* An UpdateQuery function "instantiated" with a specific item. It only takes
* the current state of the cache and returns the desired (new) state of the
* cache.
*/
type SpecificUpdateQuery<CachedData> = (oldData: CachedData) => CachedData
/**
* A specific, "instantiated" optimistic update definition which contains a
* fully-constructed query key and a specific update function.
*/
type SpecificOptimisticUpdateDefinition<Item> = {
queryKey: QueryKey;
updateQuery: SpecificUpdateQuery<Item>;
}
type InternalAction<Input, Output> = Action<Input, Output> & {
internal<CachedData extends unknown>(
item: Input,
optimisticUpdateDefinitions: SpecificOptimisticUpdateDefinition<CachedData>[]
): Promise<Output>
}
/**
* Translates/Desugars a public optimistic update definition object into a
* definition object our system uses internally.
*
* @param publicOptimisticUpdateDefinition An optimistic update definition
* object that's a part of the public API:
* https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates
* @returns An internally-used optimistic update definition object.
*/
function translateToInternalDefinition<Item, CachedData>(
publicOptimisticUpdateDefinition: OptimisticUpdateDefinition<Item, CachedData>
): InternalOptimisticUpdateDefinition<Item, CachedData> {
const { getQuerySpecifier, updateQuery } = publicOptimisticUpdateDefinition
const definitionErrors = []
if (typeof getQuerySpecifier !== 'function') {
definitionErrors.push('`getQuerySpecifier` is not a function.')
}
if (typeof updateQuery !== 'function') {
definitionErrors.push('`updateQuery` is not a function.')
}
if (definitionErrors.length) {
throw new TypeError(`Invalid optimistic update definition: ${definitionErrors.join(', ')}.`)
}
return {
getQueryKey: (item) => getRqQueryKeyFromSpecifier(getQuerySpecifier(item)),
updateQuery,
}
}
/**
* Creates a function that performs an action while telling it about the
* optimistic updates it caused.
*
* @param actionFn The Wasp Action.
* @param optimisticUpdateDefinitions The optimisitc updates the action causes.
* @returns An decorated action which performs optimistic updates.
*/
function makeOptimisticUpdateMutationFn<Input, Output, CachedData>(
actionFn: Action<Input, Output>,
optimisticUpdateDefinitions: InternalOptimisticUpdateDefinition<Input, CachedData>[]
): typeof actionFn {
return function performActionWithOptimisticUpdates(item) {
const specificOptimisticUpdateDefinitions = optimisticUpdateDefinitions.map(
generalDefinition => getOptimisticUpdateDefinitionForSpecificItem(generalDefinition, item)
)
return (actionFn as InternalAction<Input, Output>).internal(item, specificOptimisticUpdateDefinitions)
}
}
/**
* Given a ReactQuery query client and our internal definition of optimistic
* updates, this function constructs an object describing those same optimistic
* updates in a format we can pass into React Query's useMutation hook. In other
* words, it translates our optimistic updates definition into React Query's
* optimistic updates definition. Check their docs for details:
* https://tanstack.com/query/v4/docs/guides/optimistic-updates?from=reactQueryV3&original=https://react-query-v3.tanstack.com/guides/optimistic-updates
*
* @param queryClient The QueryClient instance used by React Query.
* @param optimisticUpdateDefinitions A list containing internal optimistic
* updates definition objects (i.e., a list where each object carries the
* instructions for performing particular optimistic update).
* @returns An object containing 'onMutate' and 'onError' functions
* corresponding to the given optimistic update definitions (check the docs
* linked above for details).
*/
function makeRqOptimisticUpdateOptions<ActionInput, CachedData>(
queryClient: QueryClient,
optimisticUpdateDefinitions: InternalOptimisticUpdateDefinition<ActionInput, CachedData>[]
): Pick<UseMutationOptions, "onMutate" | "onError"> {
async function onMutate(item) {
const specificOptimisticUpdateDefinitions = optimisticUpdateDefinitions.map(
generalDefinition => getOptimisticUpdateDefinitionForSpecificItem(generalDefinition, item)
)
// Cancel any outgoing refetches (so they don't overwrite our optimistic update).
// Theoretically, we can be a bit faster. Instead of awaiting the
// cancellation of all queries, we could cancel and update them in parallel.
// However, awaiting cancellation hasn't yet proven to be a performance bottleneck.
await Promise.all(specificOptimisticUpdateDefinitions.map(
({ queryKey }) => queryClient.cancelQueries(queryKey)
))
// We're using a Map to correctly serialize query keys that contain objects.
const previousData = new Map()
specificOptimisticUpdateDefinitions.forEach(({ queryKey, updateQuery }) => {
// Snapshot the currently cached value.
const previousDataForQuery: CachedData = queryClient.getQueryData(queryKey)
// Attempt to optimistically update the cache using the new value.
try {
queryClient.setQueryData(queryKey, updateQuery)
} catch (e) {
console.error("The `updateQuery` function threw an exception, skipping optimistic update:")
console.error(e)
}
// Remember the snapshotted value to restore in case of an error.
previousData.set(queryKey, previousDataForQuery)
})
return { previousData }
}
function onError(_err, _item, context) {
// All we do in case of an error is roll back all optimistic updates. We ensure
// not to do anything else because React Query rethrows the error. This allows
// the programmer to handle the error as they usually would (i.e., we want the
// error handling to work as it would if the programmer wasn't using optimistic
// updates).
context.previousData.forEach(async (data, queryKey) => {
await queryClient.cancelQueries(queryKey)
queryClient.setQueryData(queryKey, data)
})
}
return {
onMutate,
onError,
}
}
/**
* Constructs the definition for optimistically updating a specific item. It
* uses a closure over the updated item to construct an item-specific query key
* (e.g., useful when the query key depends on an ID).
*
* @param optimisticUpdateDefinition The general, "uninstantiated" optimistic
* update definition with a function for constructing the query key.
* @param item The item triggering the Action/optimistic update (i.e., the
* argument passed to the Action).
* @returns A specific optimistic update definition which corresponds to the
* provided definition and closes over the provided item.
*/
function getOptimisticUpdateDefinitionForSpecificItem<ActionInput, CachedData>(
optimisticUpdateDefinition: InternalOptimisticUpdateDefinition<ActionInput, CachedData>,
item: ActionInput
): SpecificOptimisticUpdateDefinition<CachedData> {
const { getQueryKey, updateQuery } = optimisticUpdateDefinition
return {
queryKey: getQueryKey(item),
updateQuery: (old) => updateQuery(item, old)
}
}
/**
* Translates a Wasp query specifier to a query cache key used by React Query.
*
* @param querySpecifier A query specifier that's a part of the public API:
* https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates
* @returns A cache key React Query internally uses for addressing queries.
*/
function getRqQueryKeyFromSpecifier(querySpecifier: QuerySpecifier<unknown, unknown>): QueryKey {
const [queryFn, ...otherKeys] = querySpecifier
return [...(queryFn as any).queryCacheKey, ...otherKeys]
}

View File

@ -1,104 +0,0 @@
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,
})
const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId'
let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined
export function setSessionId(sessionId: string): void {
waspAppAuthSessionId = sessionId
storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId)
apiEventsEmitter.emit('sessionId.set')
}
export function getSessionId(): string | undefined {
return waspAppAuthSessionId
}
export function clearSessionId(): void {
waspAppAuthSessionId = undefined
storage.remove(WASP_APP_AUTH_SESSION_ID_NAME)
apiEventsEmitter.emit('sessionId.clear')
}
export function removeLocalUserData(): void {
waspAppAuthSessionId = undefined
storage.clear()
apiEventsEmitter.emit('sessionId.clear')
}
api.interceptors.request.use((request) => {
const sessionId = getSessionId()
if (sessionId) {
request.headers['Authorization'] = `Bearer ${sessionId}`
}
return request
})
api.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401) {
clearSessionId()
}
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 session ID 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_SESSION_ID_NAME)) {
if (!!event.newValue) {
waspAppAuthSessionId = event.newValue
apiEventsEmitter.emit('sessionId.set')
} else {
waspAppAuthSessionId = undefined
apiEventsEmitter.emit('sessionId.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
* error has been formatted as implemented by HttpError on the server.
*/
export function handleApiError(error: AxiosError<{ message?: string, data?: unknown }>): void {
if (error?.response) {
// If error came from HTTP response, we capture most informative message
// and also add .statusCode information to it.
// If error had JSON response, we assume it is of format { message, data } and
// add that info to the error.
// TODO: We might want to use HttpError here instead of just Error, since
// HttpError is also used on server to throw errors like these.
// That would require copying HttpError code to web-app also and using it here.
const responseJson = error.response?.data
const responseStatusCode = error.response.status
throw new WaspHttpError(responseStatusCode, responseJson?.message ?? error.message, responseJson)
} else {
// If any other error, we just propagate it.
throw error
}
}
class WaspHttpError extends Error {
statusCode: number
data: unknown
constructor (statusCode: number, message: string, data: unknown) {
super(message)
this.statusCode = statusCode
this.data = data
}
}
export default api

View File

@ -1,95 +0,0 @@
import { styled } from '../../../stitches.config'
export const Form = styled('form', {
marginTop: '1.5rem',
})
export const FormItemGroup = styled('div', {
'& + div': {
marginTop: '1.5rem',
},
})
export const FormLabel = styled('label', {
display: 'block',
fontSize: '$sm',
fontWeight: '500',
marginBottom: '0.5rem',
})
const commonInputStyles = {
display: 'block',
lineHeight: '1.5rem',
fontSize: '$sm',
borderWidth: '1px',
borderColor: '$gray600',
backgroundColor: '#f8f4ff',
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
'&:focus': {
borderWidth: '1px',
borderColor: '$gray700',
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
},
'&:disabled': {
opacity: 0.5,
cursor: 'not-allowed',
backgroundColor: '$gray400',
borderColor: '$gray400',
color: '$gray500',
},
borderRadius: '0.375rem',
width: '100%',
paddingTop: '0.375rem',
paddingBottom: '0.375rem',
paddingLeft: '0.75rem',
paddingRight: '0.75rem',
margin: 0,
}
export const FormInput = styled('input', commonInputStyles)
export const FormTextarea = styled('textarea', commonInputStyles)
export const FormError = styled('div', {
display: 'block',
fontSize: '$sm',
fontWeight: '500',
color: '$formErrorText',
marginTop: '0.5rem',
})
export const SubmitButton = styled('button', {
display: 'flex',
justifyContent: 'center',
width: '100%',
borderWidth: '1px',
borderColor: '$brand',
backgroundColor: '$brand',
color: '$submitButtonText',
padding: '0.5rem 0.75rem',
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
fontWeight: '600',
fontSize: '$sm',
lineHeight: '1.25rem',
borderRadius: '0.375rem',
// TODO(matija): extract this into separate BaseButton component and then inherit it.
'&:hover': {
backgroundColor: '$brandAccent',
borderColor: '$brandAccent',
},
'&:disabled': {
opacity: 0.5,
cursor: 'not-allowed',
backgroundColor: '$gray400',
borderColor: '$gray400',
color: '$gray500',
},
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '100ms',
})

View File

@ -1,18 +0,0 @@
import { styled } from '../../../stitches.config'
export const Message = styled('div', {
padding: '0.5rem 0.75rem',
borderRadius: '0.375rem',
marginTop: '1rem',
background: '$gray400',
})
export const MessageError = styled(Message, {
background: '$errorBackground',
color: '$errorText',
})
export const MessageSuccess = styled(Message, {
background: '$successBackground',
color: '$successText',
})

View File

@ -1,14 +0,0 @@
import { setSessionId } from '../../api'
import { invalidateAndRemoveQueries } from '../../operations/resources'
export async function initSession(sessionId: string): Promise<void> {
setSessionId(sessionId)
// We need to invalidate queries after login in order to get the correct user
// data in the React components (using `useAuth`).
// Redirects after login won't work properly without this.
// TODO(filip): We are currently removing all the queries, but we should
// remove only non-public, user-dependent queries - public queries are
// expected not to change in respect to the currently logged in user.
await invalidateAndRemoveQueries()
}

View File

@ -1,17 +0,0 @@
import api, { removeLocalUserData } from '../api'
import { invalidateAndRemoveQueries } from '../operations/resources'
export default async function logout(): Promise<void> {
try {
await api.post('/auth/logout')
} finally {
// Even if the logout request fails, we still want to remove the local user data
// in case the logout failed because of a network error and the user walked away
// from the computer.
removeLocalUserData()
// TODO(filip): We are currently invalidating and removing all the queries, but
// we should remove only the non-public, user-dependent ones.
await invalidateAndRemoveQueries()
}
}

View File

@ -2,9 +2,9 @@
import React, { useEffect, useRef } from 'react' import React, { useEffect, useRef } from 'react'
import { useHistory } from 'react-router-dom' import { useHistory } from 'react-router-dom'
import config from '../../config.js' import config from 'wasp/core/config'
import api from '../../api' import api from 'wasp/api'
import { initSession } from '../helpers/user' import { initSession } from 'wasp/auth/helpers/user'
// After a user authenticates via an Oauth 2.0 provider, this is the page that // After a user authenticates via an Oauth 2.0 provider, this is the page that
// the provider should redirect them to, while providing query string parameters // the provider should redirect them to, while providing query string parameters

View File

@ -2,7 +2,7 @@
import React from 'react' import React from 'react'
import { Redirect } from 'react-router-dom' import { Redirect } from 'react-router-dom'
import useAuth from '../useAuth' import useAuth from 'wasp/auth/useAuth'
const createAuthRequiredPage = (Page) => { const createAuthRequiredPage = (Page) => {

View File

@ -1,2 +0,0 @@
// todo(filip): turn into a proper import/path
export type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from '../../../server/src/_types/'

View File

@ -1,40 +0,0 @@
{{={= =}=}}
import { deserialize as superjsonDeserialize } from 'superjson'
import { useQuery } from '../queries'
import api, { handleApiError } from '../api'
import { HttpMethod } from '../types'
import type { User } from './types'
import { addMetadataToQuery } from '../queries/core'
export const getMe = createUserGetter()
export default function useAuth(queryFnArgs?: unknown, config?: any) {
return useQuery(getMe, queryFnArgs, config)
}
function createUserGetter() {
const getMeRelativePath = 'auth/me'
const getMeRoute = { method: HttpMethod.Get, path: `/${getMeRelativePath}` }
async function getMe(): Promise<User | null> {
try {
const response = await api.get(getMeRoute.path)
return superjsonDeserialize(response.data)
} catch (error) {
if (error.response?.status === 401) {
return null
} else {
handleApiError(error)
}
}
}
addMetadataToQuery(getMe, {
relativeQueryPath: getMeRelativePath,
queryRoute: getMeRoute,
entitiesUsed: {=& entitiesGetMeDependsOn =},
})
return getMe
}

View File

@ -1,27 +0,0 @@
// We decided not to deduplicate these helper functions in the server and the client.
// We have them duplicated in this file and in data/Generator/templates/server/src/auth/user.ts
// If you are changing the logic here, make sure to change it there as well.
import type { User, ProviderName, DeserializedAuthIdentity } from './types'
export function getEmail(user: User): string | null {
return findUserIdentity(user, "email")?.providerUserId ?? null;
}
export function getUsername(user: User): string | null {
return findUserIdentity(user, "username")?.providerUserId ?? null;
}
export function getFirstProviderUserId(user?: User): string | null {
if (!user || !user.auth || !user.auth.identities || user.auth.identities.length === 0) {
return null;
}
return user.auth.identities[0].providerUserId ?? null;
}
export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthIdentity | undefined {
return user.auth.identities.find(
(identity) => identity.providerName === providerName
);
}

View File

@ -14,7 +14,7 @@ import {
{=/ setupFn.isDefined =} {=/ setupFn.isDefined =}
{=# areWebSocketsUsed =} {=# areWebSocketsUsed =}
import { WebSocketProvider } from './webSocket/WebSocketProvider' import { WebSocketProvider } from 'wasp/webSocket/WebSocketProvider'
{=/ areWebSocketsUsed =} {=/ areWebSocketsUsed =}
startApp() startApp()

View File

@ -1,22 +0,0 @@
import api, { handleApiError } from '../api'
import { HttpMethod } from '../types'
import {
serialize as superjsonSerialize,
deserialize as superjsonDeserialize,
} from 'superjson'
export type OperationRoute = { method: HttpMethod, path: string }
export async function callOperation(operationRoute: OperationRoute & { method: HttpMethod.Post }, args: any) {
try {
const superjsonArgs = superjsonSerialize(args)
const response = await api.post(operationRoute.path, superjsonArgs)
return superjsonDeserialize(response.data)
} catch (error) {
handleApiError(error)
}
}
export function makeOperationRoute(relativeOperationRoute: string): OperationRoute {
return { method: HttpMethod.Post, path: `/${relativeOperationRoute}` }
}

View File

@ -1,81 +0,0 @@
import { queryClientInitialized } from '../queryClient'
import { makeUpdateHandlersMap } from './updateHandlersMap'
import { hashQueryKey } from '@tanstack/react-query'
// Map where key is resource name and value is Set
// containing query ids of all the queries that use
// that resource.
const resourceToQueryCacheKeys = new Map()
const updateHandlers = makeUpdateHandlersMap(hashQueryKey)
/**
* Remembers that specified query is using specified resources.
* If called multiple times for same query, resources are added, not reset.
* @param {string[]} queryCacheKey - Unique key under used to identify query in the cache.
* @param {string[]} resources - Names of resources that query is using.
*/
export function addResourcesUsedByQuery(queryCacheKey, resources) {
for (const resource of resources) {
let cacheKeys = resourceToQueryCacheKeys.get(resource)
if (!cacheKeys) {
cacheKeys = new Set()
resourceToQueryCacheKeys.set(resource, cacheKeys)
}
cacheKeys.add(queryCacheKey)
}
}
export function registerActionInProgress(optimisticUpdateTuples) {
optimisticUpdateTuples.forEach(
({ queryKey, updateQuery }) => updateHandlers.add(queryKey, updateQuery)
)
}
export async function registerActionDone(resources, optimisticUpdateTuples) {
optimisticUpdateTuples.forEach(({ queryKey }) => updateHandlers.remove(queryKey))
await invalidateQueriesUsing(resources)
}
export function getActiveOptimisticUpdates(queryKey) {
return updateHandlers.getUpdateHandlers(queryKey)
}
export async function invalidateAndRemoveQueries() {
const queryClient = await queryClientInitialized
// If we don't reset the queries before removing them, Wasp will stay on
// the same page. The user would have to manually refresh the page to "finish"
// logging out.
// When a query is removed, the `Observer` is removed as well, and the components
// that are using the query are not re-rendered. This is why we need to reset
// the queries, so that the `Observer` is re-created and the components are re-rendered.
// For more details: https://github.com/wasp-lang/wasp/pull/1014/files#r1111862125
queryClient.resetQueries()
// If we don't remove the queries after invalidating them, the old query data
// remains in the cache, casuing a potential privacy issue.
queryClient.removeQueries()
}
/**
* Invalidates all queries that are using specified resources.
* @param {string[]} resources - Names of resources.
*/
async function invalidateQueriesUsing(resources) {
const queryClient = await queryClientInitialized
const queryCacheKeysToInvalidate = getQueriesUsingResources(resources)
queryCacheKeysToInvalidate.forEach(
queryCacheKey => queryClient.invalidateQueries(queryCacheKey)
)
}
/**
* @param {string} resource - Resource name.
* @returns {string[]} Array of "query cache keys" of queries that use specified resource.
*/
function getQueriesUsingResource(resource) {
return Array.from(resourceToQueryCacheKeys.get(resource) || [])
}
function getQueriesUsingResources(resources) {
return Array.from(new Set(resources.flatMap(getQueriesUsingResource)))
}

View File

@ -1,11 +0,0 @@
{{={= =}=}}
import { createQuery } from './core'
{=& operationTypeImportStmt =}
const query = createQuery<{= operationTypeName =}>(
'{= queryRoute =}',
{=& entitiesArray =},
)
export default query

View File

@ -1,23 +0,0 @@
import { type Query } from '.'
import { Route } from '../types';
import type { Expand, _Awaited, _ReturnType } from '../universal/types'
export function createQuery<BackendQuery extends GenericBackendQuery>(
queryRoute: string,
entitiesUsed: any[]
): QueryFor<BackendQuery>
export function addMetadataToQuery(
query: (...args: any[]) => Promise<unknown>,
metadata: {
relativeQueryPath: string;
queryRoute: Route;
entitiesUsed: string[];
},
): void
type QueryFor<BackendQuery extends GenericBackendQuery> = Expand<
Query<Parameters<BackendQuery>[0], _Awaited<_ReturnType<BackendQuery>>>
>
type GenericBackendQuery = (args: never, context: any) => unknown

View File

@ -1,27 +0,0 @@
import { callOperation, makeOperationRoute } from '../operations'
import {
addResourcesUsedByQuery,
getActiveOptimisticUpdates,
} from '../operations/resources'
export function createQuery(relativeQueryPath, entitiesUsed) {
const queryRoute = makeOperationRoute(relativeQueryPath)
async function query(queryKey, queryArgs) {
const serverResult = await callOperation(queryRoute, queryArgs)
return getActiveOptimisticUpdates(queryKey).reduce(
(result, update) => update(result),
serverResult,
)
}
addMetadataToQuery(query, { relativeQueryPath, queryRoute, entitiesUsed })
return query
}
export function addMetadataToQuery(query, { relativeQueryPath, queryRoute, entitiesUsed }) {
query.queryCacheKey = [relativeQueryPath]
query.route = queryRoute
addResourcesUsedByQuery(query.queryCacheKey, entitiesUsed)
}

View File

@ -1,10 +0,0 @@
import { UseQueryResult } from "@tanstack/react-query";
export type Query<Input, Output> = {
(queryCacheKey: string[], args: Input): Promise<Output>
}
export function useQuery<Input, Output>(
queryFn: Query<Input, Output>,
queryFnArgs?: Input, options?: any
): UseQueryResult<Output, Error>

View File

@ -1,18 +0,0 @@
import { useQuery as rqUseQuery } from '@tanstack/react-query'
export { configureQueryClient } from '../queryClient'
export function useQuery(queryFn, queryFnArgs, options) {
if (typeof queryFn !== 'function') {
throw new TypeError('useQuery requires queryFn to be a function.')
}
if (!queryFn.queryCacheKey) {
throw new TypeError('queryFn needs to have queryCacheKey property defined.')
}
const queryKey = queryFnArgs !== undefined ? [...queryFn.queryCacheKey, queryFnArgs] : queryFn.queryCacheKey
return rqUseQuery({
queryKey,
queryFn: () => queryFn(queryKey, queryFnArgs),
...options
})
}

View File

@ -1,18 +1,12 @@
{{={= =}=}} {{={= =}=}}
import React from 'react' import React from 'react'
import { Route, Switch, BrowserRouter as Router } from 'react-router-dom' import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
import { interpolatePath } from './router/linkHelpers'
import type {
RouteDefinitionsToRoutes,
OptionalRouteOptions,
ParamValue,
} from './router/types'
{=# rootComponent.isDefined =} {=# rootComponent.isDefined =}
{=& rootComponent.importStatement =} {=& rootComponent.importStatement =}
{=/ rootComponent.isDefined =} {=/ rootComponent.isDefined =}
{=# isAuthEnabled =} {=# isAuthEnabled =}
import createAuthRequiredPage from "wasp/auth/pages/createAuthRequiredPage" import createAuthRequiredPage from "./auth/pages/createAuthRequiredPage"
{=/ isAuthEnabled =} {=/ isAuthEnabled =}
{=# pagesToImport =} {=# pagesToImport =}
@ -23,29 +17,14 @@ import createAuthRequiredPage from "wasp/auth/pages/createAuthRequiredPage"
import OAuthCodeExchange from "./auth/pages/OAuthCodeExchange" import OAuthCodeExchange from "./auth/pages/OAuthCodeExchange"
{=/ isExternalAuthEnabled =} {=/ isExternalAuthEnabled =}
export const routes = { import { routes } from 'wasp/router'
export const routeNameToRouteComponent = {
{=# routes =} {=# routes =}
{= name =}: { {= name =}: {= targetComponent =},
to: "{= urlPath =}",
component: {= targetComponent =},
{=# hasUrlParams =}
build: (
options: {
params: {{=# urlParams =}{= name =}{=# isOptional =}?{=/ isOptional =}: ParamValue;{=/ urlParams =}}
} & OptionalRouteOptions,
) => interpolatePath("{= urlPath =}", options.params, options.search, options.hash),
{=/ hasUrlParams =}
{=^ hasUrlParams =}
build: (
options?: OptionalRouteOptions,
) => interpolatePath("{= urlPath =}", undefined, options.search, options.hash),
{=/ hasUrlParams =}
},
{=/ routes =} {=/ routes =}
} as const; } as const;
export type Routes = RouteDefinitionsToRoutes<typeof routes>
const router = ( const router = (
<Router basename="{= baseDir =}"> <Router basename="{= baseDir =}">
{=# rootComponent.isDefined =} {=# rootComponent.isDefined =}
@ -57,7 +36,7 @@ const router = (
exact exact
key={routeKey} key={routeKey}
path={route.to} path={route.to}
component={route.component} component={routeNameToRouteComponent[routeKey]}
/> />
))} ))}
{=# isExternalAuthEnabled =} {=# isExternalAuthEnabled =}
@ -77,5 +56,3 @@ const router = (
) )
export default router export default router
export { Link } from './router/Link'

View File

@ -1,4 +1,8 @@
import matchers from '@testing-library/jest-dom/matchers' import { afterEach } from 'vitest'
import { expect } from 'vitest' import { cleanup } from '@testing-library/react'
import '@testing-library/jest-dom/vitest'
expect.extend(matchers) // runs a clean after each test case (e.g. clearing jsdom)
afterEach(() => {
cleanup();
})

View File

@ -1,9 +0,0 @@
// NOTE: This is enough to cover Operations and our APIs (src/Wasp/AppSpec/Api.hs).
export enum HttpMethod {
Get = 'GET',
Post = 'POST',
Put = 'PUT',
Delete = 'DELETE',
}
export type Route = { method: HttpMethod; path: string }

View File

@ -6,7 +6,7 @@
"allowJs": true, "allowJs": true,
"strict": false, "strict": false,
// Allow importing pages with the .tsx extension. // Allow importing pages with the .tsx extension.
"allowImportingTsExtensions": true "allowImportingTsExtensions": true,
}, },
"include": [ "include": [
"src" "src"

View File

@ -2,8 +2,11 @@
/// <reference types="vitest" /> /// <reference types="vitest" />
import { mergeConfig } from "vite"; import { mergeConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defaultExclude } from "vitest/config"
{=# customViteConfig.isDefined =} {=# customViteConfig.isDefined =}
// Ignoring the TS error because we are importing a file outside of TS root dir.
// @ts-ignore
{=& customViteConfig.importStatement =} {=& customViteConfig.importStatement =}
const _waspUserProvidedConfig = {=& customViteConfig.importIdentifier =} const _waspUserProvidedConfig = {=& customViteConfig.importIdentifier =}
{=/ customViteConfig.isDefined =} {=/ customViteConfig.isDefined =}
@ -27,12 +30,16 @@ const defaultViteConfig = {
outDir: "build", outDir: "build",
}, },
test: { test: {
globals: true,
environment: "jsdom", environment: "jsdom",
setupFiles: ["./src/test/vitest/setup.ts"], // vitest is running from the root of the project, so we need
// to specify the path to the setup file relative to the root.
setupFiles: {=& vitest.setupFilesArray =},
exclude: [
...defaultExclude,
"{= vitest.excludeWaspArtefactsPattern =}",
]
}, },
// resolve: {
// dedupe: ["react", "react-dom"],
// },
}; };
// https://vitejs.dev/config/ // https://vitejs.dev/config/

View File

@ -2,7 +2,7 @@ import axios, { type AxiosError } from 'axios'
import config from 'wasp/core/config' import config from 'wasp/core/config'
import { storage } from 'wasp/core/storage' import { storage } from 'wasp/core/storage'
import { apiEventsEmitter } from 'wasp/api/events' import { apiEventsEmitter } from './events.js'
const api = axios.create({ const api = axios.create({
baseURL: config.apiUrl, baseURL: config.apiUrl,

View File

@ -1,5 +1,5 @@
{{={= =}=}} {{={= =}=}}
import api, { handleApiError } from '../../../api'; import api, { handleApiError } from 'wasp/api';
import { initSession } from '../../helpers/user'; import { initSession } from '../../helpers/user';
export async function login(data: { email: string; password: string }): Promise<void> { export async function login(data: { email: string; password: string }): Promise<void> {

View File

@ -1,5 +1,5 @@
{{={= =}=}} {{={= =}=}}
import api, { handleApiError } from '../../../api'; import api, { handleApiError } from 'wasp/api';
export async function requestPasswordReset(data: { email: string; }): Promise<{ success: boolean }> { export async function requestPasswordReset(data: { email: string; }): Promise<{ success: boolean }> {
try { try {

View File

@ -1,5 +1,5 @@
{{={= =}=}} {{={= =}=}}
import api, { handleApiError } from '../../../api'; import api, { handleApiError } from 'wasp/api';
export async function signup(data: { email: string; password: string }): Promise<{ success: boolean }> { export async function signup(data: { email: string; password: string }): Promise<{ success: boolean }> {
try { try {

View File

@ -1,5 +1,5 @@
{{={= =}=}} {{={= =}=}}
import api, { handleApiError } from '../../../api' import api, { handleApiError } from 'wasp/api'
export async function verifyEmail(data: { export async function verifyEmail(data: {
token: string token: string

View File

@ -1,7 +1,7 @@
{{={= =}=}} {{={= =}=}}
import { useState, createContext } from 'react' import { useState, createContext } from 'react'
import { createTheme } from '@stitches/react' import { createTheme } from '@stitches/react'
import { styled } from '../../stitches.config' import { styled } from 'wasp/core/stitches.config'
import { import {
type State, type State,

View File

@ -1,8 +1,8 @@
{{={= =}=}} {{={= =}=}}
import { useContext } from 'react' import { useContext } from 'react'
import { useForm, UseFormReturn } from 'react-hook-form' import { useForm, UseFormReturn } from 'react-hook-form'
import { styled } from '../../../../stitches.config' import { styled } from 'wasp/core/stitches.config'
import config from '../../../../config' import config from 'wasp/core/config'
import { AuthContext } from '../../Auth' import { AuthContext } from '../../Auth'
import { import {

View File

@ -1,4 +1,4 @@
import { styled } from '../../../../stitches.config' import { styled } from 'wasp/core/stitches.config'
export const SocialButton = styled('a', { export const SocialButton = styled('a', {
display: 'flex', display: 'flex',

View File

@ -1,6 +1,6 @@
{{={= =}=}} {{={= =}=}}
import config from '../../config.js' import config from 'wasp/core/config'
import { SocialButton } from '../forms/internal/social/SocialButton' import { SocialButton } from '../forms/internal/social/SocialButton'
import * as SocialIcons from '../forms/internal/social/SocialIcons' import * as SocialIcons from '../forms/internal/social/SocialIcons'

View File

@ -1,7 +1,7 @@
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import util from 'util' import util from 'util'
import config from '../config.js' import config from 'wasp/server/config'
const jwtSign = util.promisify(jwt.sign) const jwtSign = util.promisify(jwt.sign)
const jwtVerify = util.promisify(jwt.verify) const jwtVerify = util.promisify(jwt.verify)

View File

@ -1,5 +1,5 @@
{{={= =}=}} {{={= =}=}}
import api, { handleApiError } from '../api' import api, { handleApiError } from 'wasp/api'
import { initSession } from './helpers/user' import { initSession } from './helpers/user'
export default async function login(username: string, password: string): Promise<void> { export default async function login(username: string, password: string): Promise<void> {

View File

@ -1,9 +1,8 @@
{{={= =}=}} {{={= =}=}}
import { Lucia } from "lucia"; import { Lucia } from "lucia";
import { PrismaAdapter } from "@lucia-auth/adapter-prisma"; import { PrismaAdapter } from "@lucia-auth/adapter-prisma";
import prisma from '../dbClient.js' import prisma from 'wasp/server/dbClient'
import config from '../config.js' import { type {= userEntityUpper =} } from "wasp/entities"
import { type {= userEntityUpper =} } from "../entities/index.js"
const prismaAdapter = new PrismaAdapter( const prismaAdapter = new PrismaAdapter(
// Using `as any` here since Lucia's model types are not compatible with Prisma 4 // Using `as any` here since Lucia's model types are not compatible with Prisma 4

View File

@ -1,7 +1,7 @@
{{={= =}=}} {{={= =}=}}
import type { Router, Request } from 'express' import type { Router, Request } from 'express'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import type { Expand } from '../../universal/types' import type { Expand } from 'wasp/universal/types'
import type { ProviderName } from '../utils' import type { ProviderName } from '../utils'
type UserEntityCreateInput = Prisma.{= userEntityUpper =}CreateInput type UserEntityCreateInput = Prisma.{= userEntityUpper =}CreateInput

View File

@ -1,8 +1,8 @@
{{={= =}=}} {{={= =}=}}
import { Request as ExpressRequest } from "express"; import { Request as ExpressRequest } from "express";
import { type {= userEntityUpper =} } from "../entities/index.js" import { type {= userEntityUpper =} } from "wasp/entities"
import { type SanitizedUser } from '../_types/index.js' import { type SanitizedUser } from 'wasp/server/_types'
import { auth } from "./lucia.js"; import { auth } from "./lucia.js";
import type { Session } from "lucia"; import type { Session } from "lucia";
@ -11,7 +11,7 @@ import {
deserializeAndSanitizeProviderData, deserializeAndSanitizeProviderData,
} from "./utils.js"; } from "./utils.js";
import prisma from '../dbClient.js'; import prisma from 'wasp/server/dbClient';
// Creates a new session for the `authId` in the database // Creates a new session for the `authId` in the database
export async function createSession(authId: string): Promise<Session> { export async function createSession(authId: string): Promise<Session> {

View File

@ -1,5 +1,5 @@
{{={= =}=}} {{={= =}=}}
import api, { handleApiError } from '../api' import api, { handleApiError } from 'wasp/api'
export default async function signup(userFields: { username: string; password: string }): Promise<void> { export default async function signup(userFields: { username: string; password: string }): Promise<void> {
try { try {

View File

@ -1,8 +1,9 @@
{{={= =}=}}
import { deserialize as superjsonDeserialize } from 'superjson' import { deserialize as superjsonDeserialize } from 'superjson'
import { useQuery } from 'wasp/rpc' import { useQuery } from 'wasp/rpc'
import api, { handleApiError } from 'wasp/api' import api, { handleApiError } from 'wasp/api'
import { HttpMethod } from 'wasp/types' import { HttpMethod } from 'wasp/types'
import type { User } from './types' import type { User } from './types'
import { addMetadataToQuery } from 'wasp/rpc/queries' import { addMetadataToQuery } from 'wasp/rpc/queries'
export const getMe = createUserGetter() export const getMe = createUserGetter()
@ -31,7 +32,7 @@ function createUserGetter() {
addMetadataToQuery(getMe, { addMetadataToQuery(getMe, {
relativeQueryPath: getMeRelativePath, relativeQueryPath: getMeRelativePath,
queryRoute: getMeRoute, queryRoute: getMeRoute,
entitiesUsed: ['User'], entitiesUsed: {=& entitiesGetMeDependsOn =},
}) })
return getMe return getMe

View File

@ -1,8 +1,4 @@
// We decided not to deduplicate these helper functions in the server and the client. import type { User, ProviderName, DeserializedAuthIdentity } from './types'
// We have them duplicated in this file and in data/Generator/templates/server/src/auth/user.ts
// If you are changing the logic here, make sure to change it there as well.
import type { User, ProviderName, DeserializedAuthIdentity } from './types'
export function getEmail(user: User): string | null { export function getEmail(user: User): string | null {
return findUserIdentity(user, "email")?.providerUserId ?? null; return findUserIdentity(user, "email")?.providerUserId ?? null;

View File

@ -1,14 +1,15 @@
{{={= =}=}}
import { hashPassword } from './password.js' import { hashPassword } from './password.js'
import { verify } from './jwt.js' import { verify } from './jwt.js'
import AuthError from '../core/AuthError.js' import AuthError from 'wasp/core/AuthError'
import HttpError from '../core/HttpError.js' import HttpError from 'wasp/core/HttpError'
import prisma from '../server/dbClient.js' import prisma from 'wasp/server/dbClient'
import { sleep } from '../server/utils' import { sleep } from 'wasp/server/utils'
import { import {
type User, type {= userEntityUpper =},
type Auth, type {= authEntityUpper =},
type AuthIdentity, type {= authIdentityEntityUpper =},
} from '../entities' } from 'wasp/entities'
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { throwValidationError } from './validation.js' import { throwValidationError } from './validation.js'
@ -46,13 +47,13 @@ export type ProviderName = keyof PossibleProviderData
export const contextWithUserEntity = { export const contextWithUserEntity = {
entities: { entities: {
User: prisma.user {= userEntityUpper =}: prisma.{= userEntityLower =}
} }
} }
export const authConfig = { export const authConfig = {
failureRedirectPath: "/login", failureRedirectPath: "{= failureRedirectPath =}",
successRedirectPath: "/", successRedirectPath: "{= successRedirectPath =}",
} }
/** /**
@ -76,8 +77,8 @@ export function createProviderId(providerName: ProviderName, providerUserId: str
} }
} }
export async function findAuthIdentity(providerId: ProviderId): Promise<AuthIdentity | null> { export async function findAuthIdentity(providerId: ProviderId): Promise<{= authIdentityEntityUpper =} | null> {
return prisma.authIdentity.findUnique({ return prisma.{= authIdentityEntityLower =}.findUnique({
where: { where: {
providerName_providerUserId: providerId, providerName_providerUserId: providerId,
} }
@ -96,7 +97,7 @@ export async function updateAuthIdentityProviderData<PN extends ProviderName>(
providerId: ProviderId, providerId: ProviderId,
existingProviderData: PossibleProviderData[PN], existingProviderData: PossibleProviderData[PN],
providerDataUpdates: Partial<PossibleProviderData[PN]>, providerDataUpdates: Partial<PossibleProviderData[PN]>,
): Promise<AuthIdentity> { ): Promise<{= authIdentityEntityUpper =}> {
// We are doing the sanitization here only on updates to avoid // We are doing the sanitization here only on updates to avoid
// hashing the password multiple times. // hashing the password multiple times.
const sanitizedProviderDataUpdates = await sanitizeProviderData(providerDataUpdates); const sanitizedProviderDataUpdates = await sanitizeProviderData(providerDataUpdates);
@ -105,7 +106,7 @@ export async function updateAuthIdentityProviderData<PN extends ProviderName>(
...sanitizedProviderDataUpdates, ...sanitizedProviderDataUpdates,
} }
const serializedProviderData = await serializeProviderData<PN>(newProviderData); const serializedProviderData = await serializeProviderData<PN>(newProviderData);
return prisma.authIdentity.update({ return prisma.{= authIdentityEntityLower =}.update({
where: { where: {
providerName_providerUserId: providerId, providerName_providerUserId: providerId,
}, },
@ -113,31 +114,31 @@ export async function updateAuthIdentityProviderData<PN extends ProviderName>(
}); });
} }
type FindAuthWithUserResult = Auth & { type FindAuthWithUserResult = {= authEntityUpper =} & {
user: User {= userFieldOnAuthEntityName =}: {= userEntityUpper =}
} }
export async function findAuthWithUserBy( export async function findAuthWithUserBy(
where: Prisma.AuthWhereInput where: Prisma.{= authEntityUpper =}WhereInput
): Promise<FindAuthWithUserResult> { ): Promise<FindAuthWithUserResult> {
return prisma.auth.findFirst({ where, include: { user: true }}); return prisma.{= authEntityLower =}.findFirst({ where, include: { {= userFieldOnAuthEntityName =}: true }});
} }
export async function createUser( export async function createUser(
providerId: ProviderId, providerId: ProviderId,
serializedProviderData?: string, serializedProviderData?: string,
userFields?: PossibleUserFields, userFields?: PossibleUserFields,
): Promise<User & { ): Promise<{= userEntityUpper =} & {
auth: Auth auth: {= authEntityUpper =}
}> { }> {
return prisma.user.create({ return prisma.{= userEntityLower =}.create({
data: { data: {
// Using any here to prevent type errors when userFields are not // Using any here to prevent type errors when userFields are not
// defined. We want Prisma to throw an error in that case. // defined. We want Prisma to throw an error in that case.
...(userFields ?? {} as any), ...(userFields ?? {} as any),
auth: { {= authFieldOnUserEntityName =}: {
create: { create: {
identities: { {= identitiesFieldOnAuthEntityName =}: {
create: { create: {
providerName: providerId.providerName, providerName: providerId.providerName,
providerUserId: providerId.providerUserId, providerUserId: providerId.providerUserId,
@ -150,13 +151,13 @@ export async function createUser(
// We need to include the Auth entity here because we need `authId` // We need to include the Auth entity here because we need `authId`
// to be able to create a session. // to be able to create a session.
include: { include: {
auth: true, {= authFieldOnUserEntityName =}: true,
}, },
}) })
} }
export async function deleteUserByAuthId(authId: string): Promise<{ count: number }> { export async function deleteUserByAuthId(authId: string): Promise<{ count: number }> {
return prisma.user.deleteMany({ where: { auth: { return prisma.{= userEntityLower =}.deleteMany({ where: { auth: {
id: authId, id: authId,
} } }) } } })
} }
@ -213,7 +214,7 @@ export function rethrowPossibleAuthError(e: unknown): void {
// Prisma code P2003 is for foreign key constraint failure // Prisma code P2003 is for foreign key constraint failure
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2003') { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2003') {
console.error(e) console.error(e)
console.info(`🐝 This error can happen if you have some relation on your User entity console.info(`🐝 This error can happen if you have some relation on your {= userEntityUpper =} entity
but you didn't specify the "onDelete" behaviour to either "Cascade" or "SetNull". but you didn't specify the "onDelete" behaviour to either "Cascade" or "SetNull".
Read more at: https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/referential-actions`) Read more at: https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/referential-actions`)
throw new HttpError(500, 'Save failed', { throw new HttpError(500, 'Save failed', {

View File

@ -1,4 +1,4 @@
import HttpError from 'wasp/core/HttpError' import HttpError from 'wasp/core/HttpError';
export const PASSWORD_FIELD = 'password'; export const PASSWORD_FIELD = 'password';
const USERNAME_FIELD = 'username'; const USERNAME_FIELD = 'username';

View File

@ -1,5 +1,7 @@
class AuthError extends Error { class AuthError extends Error {
constructor (message, data, ...params) { public data: unknown
constructor (message: string, data?: unknown, ...params: unknown[]) {
super(message, ...params) super(message, ...params)
if (Error.captureStackTrace) { if (Error.captureStackTrace) {

View File

@ -1,5 +1,8 @@
class HttpError extends Error { class HttpError extends Error {
constructor (statusCode, message, data, ...params) { public statusCode: number
public data: unknown
constructor (statusCode: number, message?: string, data?: Record<string, unknown>, ...params: unknown[]) {
super(message, ...params) super(message, ...params)
if (Error.captureStackTrace) { if (Error.captureStackTrace) {

View File

@ -1,7 +1,4 @@
import { randomInt } from 'node:crypto' import { handleRejection } from 'wasp/server/utils'
import prisma from '../server/dbClient.js'
import { handleRejection } from '../utils.js'
import { getSessionAndUserFromBearerToken } from 'wasp/auth/session' import { getSessionAndUserFromBearerToken } from 'wasp/auth/session'
import { throwInvalidCredentialsError } from 'wasp/auth/utils' import { throwInvalidCredentialsError } from 'wasp/auth/utils'

View File

@ -1,8 +1,8 @@
{{={= =}=}} {{={= =}=}}
import { createAction } from "../actions/core"; import { createAction } from "wasp/rpc/actions/core";
import { useAction } from "../actions"; import { useAction } from "wasp/rpc";
import { createQuery } from "../queries/core"; import { createQuery } from "wasp/rpc/queries/core";
import { useQuery } from "../queries"; import { useQuery } from "wasp/rpc";
import { import {
{=# operations.Get =} {=# operations.Get =}
GetQueryResolved, GetQueryResolved,
@ -19,7 +19,7 @@ import {
{=# operations.Delete =} {=# operations.Delete =}
DeleteActionResolved, DeleteActionResolved,
{=/ operations.Delete =} {=/ operations.Delete =}
} from '../../../server/src/crud/{= name =}' } from 'wasp/server/crud/{= name =}'
function createCrud() { function createCrud() {
{=# operations.Get =} {=# operations.Get =}

View File

@ -1,5 +1,5 @@
{{={= =}=}} {{={= =}=}}
import { EmailFromField } from "./types"; import { EmailFromField } from "wasp/email/core/types";
// Formats an email address and an optional name into a string that can be used // Formats an email address and an optional name into a string that can be used
// as the "from" field in an email. // as the "from" field in an email.

View File

@ -1,4 +1,4 @@
import { DummyEmailProvider, EmailSender } from "../types.js"; import { DummyEmailProvider, EmailSender } from "wasp/email/core/types";
import { getDefaultFromField } from "../helpers.js"; import { getDefaultFromField } from "../helpers.js";
const yellowColor = "\x1b[33m%s\x1b[0m"; const yellowColor = "\x1b[33m%s\x1b[0m";

View File

@ -1,6 +1,6 @@
import { NodeMailgun } from "ts-mailgun"; import { NodeMailgun } from "ts-mailgun";
import { getDefaultFromField } from "../helpers.js"; import { getDefaultFromField } from "../helpers.js";
import type { MailgunEmailProvider, EmailSender } from "../types.js"; import type { MailgunEmailProvider, EmailSender } from "wasp/email/core/types";
export function initMailgunEmailSender( export function initMailgunEmailSender(
config: MailgunEmailProvider config: MailgunEmailProvider

View File

@ -1,6 +1,6 @@
import SendGrid from "@sendgrid/mail"; import SendGrid from "@sendgrid/mail";
import { getDefaultFromField } from "../helpers.js"; import { getDefaultFromField } from "../helpers.js";
import type { SendGridProvider, EmailSender } from "../types.js"; import type { SendGridProvider, EmailSender } from "wasp/email/core/types";
export function initSendGridEmailSender( export function initSendGridEmailSender(
provider: SendGridProvider provider: SendGridProvider

View File

@ -1,6 +1,6 @@
import { createTransport } from "nodemailer"; import { createTransport } from "nodemailer";
import { formatFromField, getDefaultFromField } from "../helpers.js"; import { formatFromField, getDefaultFromField } from "../helpers.js";
import type { SMTPEmailProvider, EmailSender } from "../types.js"; import type { SMTPEmailProvider, EmailSender } from "wasp/email/core/types";
export function initSmtpEmailSender(config: SMTPEmailProvider): EmailSender { export function initSmtpEmailSender(config: SMTPEmailProvider): EmailSender {
const transporter = createTransport({ const transporter = createTransport({

View File

@ -0,0 +1,14 @@
{{={= =}=}}
import prisma from 'wasp/server/dbClient'
import type { JSONValue, JSONObject } from 'wasp/server/_types/serialization'
import { type JobFn } from '{= jobExecutorTypesImportPath =}'
{=! Used in framework code, shouldn't be public =}
export const entities = {
{=# entities =}
{= name =}: prisma.{= prismaIdentifier =},
{=/ entities =}
};
{=! Used by users, should be public =}
export type {= typeName =}<Input extends JSONObject, Output extends JSONValue | void> = JobFn<Input, Output, typeof entities>

View File

@ -0,0 +1,8 @@
import { PrismaDelegate } from 'wasp/server/_types'
import type { JSONValue, JSONObject } from 'wasp/server/_types/serialization'
export type JobFn<
Input extends JSONObject,
Output extends JSONValue | void,
Entities extends Partial<PrismaDelegate>
> = (data: Input, context: { entities: Entities }) => Promise<Output>

View File

@ -3,7 +3,7 @@ import { HttpMethod } from 'wasp/types'
import { import {
serialize as superjsonSerialize, serialize as superjsonSerialize,
deserialize as superjsonDeserialize, deserialize as superjsonDeserialize,
} from 'superjson' } from 'superjson'
export type OperationRoute = { method: HttpMethod, path: string } export type OperationRoute = { method: HttpMethod, path: string }

View File

@ -9,23 +9,142 @@
"types": "tsc --declaration --emitDeclarationOnly --stripInternal --declarationDir dist" "types": "tsc --declaration --emitDeclarationOnly --stripInternal --declarationDir dist"
}, },
"exports": { "exports": {
"./core/HttpError": "./core/HttpError.js", {=! todo(filip): Check all exports when done with SDK generation =}
"./core/AuthError": "./core/AuthError.js", {=! Some of the statements in the comments might become incorrect. =}
"./core/config": "./core/config.js", {=! "our code" means: "web-app", "server" or "SDK", or "some combination of the three". =}
"./core/stitches.config": "./core/stitches.config.js", {=! Used by users, documented. =}
"./core/storage": "./core/storage.ts", "./core/HttpError": "./dist/core/HttpError.js",
"./rpc": "./rpc/index.ts", {=! Used by users, documented. =}
"./rpc/queries": "./rpc/queries/index.ts", "./core/AuthError": "./dist/core/AuthError.js",
"./rpc/actions": "./rpc/actions/index.ts", {=! Used by our code, uncodumented (but accessible) for users. =}
"./rpc/queryClient": "./rpc/queryClient.ts", "./core/config": "./dist/core/config.js",
"./types": "./types/index.ts", {=! Used by our code, uncodumented (but accessible) for users. =}
"./auth/*": "./auth/*", "./core/stitches.config": "./dist/core/stitches.config.js",
"./api": "./api/index.ts", {=! Used by our code, uncodumented (but accessible) for users. =}
"./api/*": "./api/*", "./core/storage": "./dist/core/storage.js",
"./operations": "./operations/index.ts", "./core/auth": "./dist/core/auth.js",
"./operations/*": "./operations/*", {=! Used by users, documented. =}
"./universal/url": "./universal/url.ts", "./rpc": "./dist/rpc/index.js",
"./universal/types": "./universal/url.ts" {=! Used by users, documented. =}
"./rpc/queries": "./dist/rpc/queries/index.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./rpc/queries/core": "./dist/rpc/queries/core.js",
{=! Used by users, documented. =}
"./rpc/actions": "./dist/rpc/actions/index.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./rpc/actions/core": "./dist/rpc/actions/core.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./rpc/queryClient": "./dist/rpc/queryClient.js",
{=! Used by users, documented. =}
"./types": "./dist/types/index.js",
{=! Used by user, documented. =}
"./auth": "./dist/auth/index.js",
{=! Used by users, documented. =}
"./auth/types": "./dist/auth/types.js",
{=! Used by users, documented. =}
"./auth/login": "./dist/auth/login.js",
{=! Used by users, documented. =}
"./auth/logout": "./dist/auth/logout.js",
{=! Used by users, documented. =}
"./auth/signup": "./dist/auth/signup.js",
{=! Used by users, documented. =}
"./auth/useAuth": "./dist/auth/useAuth.js",
{=! Used by users, documented. =}
"./auth/user": "./dist/auth/user.js",
{=! Used by users, documented. =}
"./auth/email": "./dist/auth/email/index.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./auth/helpers/user": "./dist/auth/helpers/user.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./auth/session": "./dist/auth/session.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./auth/providers/types": "./dist/auth/providers/types.js",
{=! Used by user, documented. =}
"./auth/utils": "./dist/auth/utils.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./auth/password": "./dist/auth/password.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./auth/jwt": "./dist/auth/jwt.js",
{=! Used by user, documented. =}
"./auth/validation": "./dist/auth/validation.js",
{=! Used by users, documented. =}
"./auth/forms/Login": "./dist/auth/forms/Login.jsx",
{=! Used by users, documented. =}
"./auth/forms/Signup": "./dist/auth/forms/Signup.jsx",
{=! Used by users, documented. =}
"./auth/forms/VerifyEmail": "./dist/auth/forms/VerifyEmail.jsx",
{=! Used by users, documented. =}
"./auth/forms/ForgotPassword": "./dist/auth/forms/ForgotPassword.jsx",
{=! Used by users, documented. =}
"./auth/forms/ResetPassword": "./dist/auth/forms/ResetPassword.jsx",
{=! Used by users, documented. =}
"./auth/forms/internal/Form": "./dist/auth/forms/internal/Form.jsx",
{=! Used by users, documented. =}
"./auth/helpers/*": "./dist/auth/helpers/*.jsx",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./auth/pages/createAuthRequiredPage": "./dist/auth/pages/createAuthRequiredPage.jsx",
{=! Used by users, documented. =}
"./api": "./dist/api/index.js",
{=! Used by our framework code (Websockets), undocumented (but accessible) for users. =}
"./api/events": "./dist/api/events.js",
{=! Used by users, documented. =}
"./operations": "./dist/operations/index.js",
{=! If we import a symbol like "import something form 'wasp/something'", we must =}
{=! expose it here (which leaks it to our users). We could avoid this by =}
{=! using relative imports inside SDK code (instead of library imports), =}
{=! but I didn't have time to implement it. =}
"./ext-src/*": "./dist/ext-src/*.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./operations/*": "./dist/operations/*",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./universal/url": "./dist/universal/url.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./universal/types": "./dist/universal/types.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./universal/validators": "./dist/universal/validators.js",
{=! Used by users and by our code, documented =}
"./server/dbClient": "./dist/server/dbClient.js",
{=! Used by users and by our code, documented. =}
"./server/config": "./dist/server/config.js",
{=! Used by users and by our code, documented. =}
"./server/types": "./dist/server/types/index.js",
{=! Used by users and by our code, documented. =}
"./server/middleware": "./dist/server/middleware/index.js",
{=! Parts are used by users, documented. Parts are probably used by our code, undocumented (but accessible). =}
"./server/utils": "./dist/server/utils.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./server/actions": "./dist/server/actions/index.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./server/queries": "./dist/server/queries/index.js",
{=! Used by users and also by our code, documented. =}
"./server/auth/email": "./dist/server/auth/email/index.js",
{=! Used by users, documented. =}
"./dbSeed/types": "./dist/dbSeed/types.js",
{=! Used by users, documented. =}
"./test": "./dist/test/index.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./test/*": "./dist/test/*.js",
"./crud/*": "./dist/crud/*.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./server/crud/*": "./dist/server/crud/*",
"./email": "./dist/email/index.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./email/core/types": "./dist/email/core/types.js",
{=! Used by users, documented. =}
"./server/auth/email/utils": "./dist/server/auth/email/utils.js",
{=! Parts are used by users and documented (types), other parts are used by the framework code (entities). =}
"./jobs/*": "./dist/jobs/*.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
{=! Todo(filip): This export becomes problematic once we start supporting different executors =}
"./jobs/pgBoss/types": "./dist/jobs/pgBoss/types.js",
{=! Used by users, documented. =}
"./router": "./dist/router/index.js",
{=! Used by users, documented. =}
"./server/webSocket": "./dist/server/webSocket/index.js",
{=! Used by users, documented. =}
"./webSocket": "./dist/webSocket/index.js",
{=! Used by our code, uncodumented (but accessible) for users. =}
"./webSocket/WebSocketProvider": "./dist/webSocket/WebSocketProvider.jsx"
}, },
"license": "ISC", "license": "ISC",
"include": [ "include": [

View File

@ -1,7 +1,7 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { Link as RouterLink } from 'react-router-dom' import { Link as RouterLink } from 'react-router-dom'
import { type Routes } from '../router'
import { interpolatePath } from './linkHelpers' import { interpolatePath } from './linkHelpers'
import { type Routes } from './index'
type RouterLinkProps = Parameters<typeof RouterLink>[0] type RouterLinkProps = Parameters<typeof RouterLink>[0]

View File

@ -0,0 +1,31 @@
{{={= =}=}}
import { interpolatePath } from './linkHelpers'
import type {
RouteDefinitionsToRoutes,
OptionalRouteOptions,
ParamValue,
} from './types'
export const routes = {
{=# routes =}
{= name =}: {
to: "{= urlPath =}",
{=# hasUrlParams =}
build: (
options: {
params: {{=# urlParams =}{= name =}{=# isOptional =}?{=/ isOptional =}: ParamValue;{=/ urlParams =}}
} & OptionalRouteOptions,
) => interpolatePath("{= urlPath =}", options.params, options?.search, options?.hash),
{=/ hasUrlParams =}
{=^ hasUrlParams =}
build: (
options?: OptionalRouteOptions,
) => interpolatePath("{= urlPath =}", undefined, options?.search, options?.hash),
{=/ hasUrlParams =}
},
{=/ routes =}
} as const;
export type Routes = RouteDefinitionsToRoutes<typeof routes>
export { Link } from './Link'

Some files were not shown because too many files have changed in this diff Show More