diff --git a/examples/streaming/src/client/vite-env.d.ts b/examples/streaming/src/client/vite-env.d.ts index 1623b9c79c..11f02fe2a0 100644 --- a/examples/streaming/src/client/vite-env.d.ts +++ b/examples/streaming/src/client/vite-env.d.ts @@ -1 +1 @@ -/// +/// diff --git a/examples/todo-typescript/migrations/20240119141512_add_session/migration.sql b/examples/todo-typescript/migrations/20240119141512_add_session/migration.sql deleted file mode 100644 index ae20ab49ba..0000000000 --- a/examples/todo-typescript/migrations/20240119141512_add_session/migration.sql +++ /dev/null @@ -1,13 +0,0 @@ --- CreateTable -CREATE TABLE "Session" ( - "id" TEXT NOT NULL PRIMARY KEY, - "expiresAt" DATETIME NOT NULL, - "userId" TEXT NOT NULL, - CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE -); - --- CreateIndex -CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id"); - --- CreateIndex -CREATE INDEX "Session_userId_idx" ON "Session"("userId"); diff --git a/waspc/cli/src/Wasp/Cli/Common.hs b/waspc/cli/src/Wasp/Cli/Common.hs index 282cf40dfe..fe4a66858c 100644 --- a/waspc/cli/src/Wasp/Cli/Common.hs +++ b/waspc/cli/src/Wasp/Cli/Common.hs @@ -44,13 +44,13 @@ dotWaspInfoFileInGeneratedCodeDir :: Path' (Rel Wasp.Generator.Common.ProjectRoo dotWaspInfoFileInGeneratedCodeDir = [relfile|.waspinfo|] extServerCodeDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir SourceExternalCodeDir) -extServerCodeDirInWaspProjectDir = [reldir|src/server|] +extServerCodeDirInWaspProjectDir = [reldir|src|] extClientCodeDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir SourceExternalCodeDir) -extClientCodeDirInWaspProjectDir = [reldir|src/client|] +extClientCodeDirInWaspProjectDir = [reldir|src|] extSharedCodeDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir SourceExternalCodeDir) -extSharedCodeDirInWaspProjectDir = [reldir|src/shared|] +extSharedCodeDirInWaspProjectDir = [reldir|src|] waspSays :: String -> IO () waspSays what = putStrLn $ Term.applyStyles [Term.Yellow] what diff --git a/waspc/data/Cli/templates/basic/package.json b/waspc/data/Cli/templates/basic/package.json new file mode 100644 index 0000000000..9657a7af1e --- /dev/null +++ b/waspc/data/Cli/templates/basic/package.json @@ -0,0 +1,10 @@ +{ + "name": "prototype", + "dependencies": { + "wasp": "file:.wasp/out/sdk/wasp", + "react": "18.2.0" + }, + "devDependencies": { + "@types/react": "^18.0.37" + } +} diff --git a/waspc/data/Generator/templates/react-app/src/index.tsx b/waspc/data/Generator/templates/react-app/src/index.tsx index dc0c1171ed..da29899438 100644 --- a/waspc/data/Generator/templates/react-app/src/index.tsx +++ b/waspc/data/Generator/templates/react-app/src/index.tsx @@ -7,7 +7,7 @@ import router from './router' import { initializeQueryClient, queryClientInitialized, -} from './queryClient' +} from 'wasp/rpc/queryClient' {=# setupFn.isDefined =} {=& setupFn.importStatement =} diff --git a/waspc/data/Generator/templates/react-app/src/router.tsx b/waspc/data/Generator/templates/react-app/src/router.tsx index 1c290df6d0..ed1de164a0 100644 --- a/waspc/data/Generator/templates/react-app/src/router.tsx +++ b/waspc/data/Generator/templates/react-app/src/router.tsx @@ -12,7 +12,7 @@ import type { {=/ rootComponent.isDefined =} {=# isAuthEnabled =} -import createAuthRequiredPage from "./auth/pages/createAuthRequiredPage" +import createAuthRequiredPage from "wasp/auth/pages/createAuthRequiredPage" {=/ isAuthEnabled =} {=# pagesToImport =} diff --git a/waspc/data/Generator/templates/react-app/tsconfig.json b/waspc/data/Generator/templates/react-app/tsconfig.json index 968a1bb47f..263338d1ce 100644 --- a/waspc/data/Generator/templates/react-app/tsconfig.json +++ b/waspc/data/Generator/templates/react-app/tsconfig.json @@ -8,6 +8,12 @@ // Allow importing pages with the .tsx extension. "allowImportingTsExtensions": true }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] -} + "include": [ + "src" + ], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] +} \ No newline at end of file diff --git a/waspc/data/Generator/templates/react-app/vite.config.ts b/waspc/data/Generator/templates/react-app/vite.config.ts index 31e1055ddb..f7fd3d8720 100644 --- a/waspc/data/Generator/templates/react-app/vite.config.ts +++ b/waspc/data/Generator/templates/react-app/vite.config.ts @@ -14,6 +14,9 @@ const _waspUserProvidedConfig = {}; const defaultViteConfig = { base: "{= baseDir =}", plugins: [react()], + optimizeDeps: { + exclude: ['wasp'] + }, server: { port: {= defaultClientPort =}, host: "0.0.0.0", diff --git a/waspc/data/Generator/templates/sdk/dependencies.txt b/waspc/data/Generator/templates/sdk/dependencies.txt new file mode 100644 index 0000000000..c15f2b5a77 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/dependencies.txt @@ -0,0 +1,127 @@ +Dependencies: + +("@prisma/client", show prismaVersion), // sdk +("@tanstack/react-query", "^4.29.0"), // sdk +("axios", "^1.4.0"), // sdk +("cookie-parser", "~1.4.6"), // +("cors", "^2.8.5"), // +("dotenv", "16.0.2"), // +("express", "~4.18.1"), // sdk (for types) +("helmet", "^6.0.0"), // +("jsonwebtoken", "^8.5.1"), // sdk +("lodash.merge", "^4.6.2"), // +("mitt", "3.0.0"), // sdk +("morgan", "~1.10.0"), // +("patch-package", "^6.4.7"), // +("rate-limiter-flexible", "^2.4.1"), // +("react", "^18.2.0"), // sdk +("react-dom", "^18.2.0"), // +("react-hook-form", "^7.45.4") // +("react-router-dom", "^5.3.3"), // sdk +("secure-password", "^4.0.0"), // sdk +("superjson", "^1.12.2"), // sdk +("uuid", "^9.0.0"), // + +Dev dependencies: +("@tsconfig/node" ++ show (major NodeVersion.latestMajorNodeVersion), "^1.0.1"), +("@tsconfig/vite-react", "^2.0.0") +("@types/cors", "^2.8.5") +("@types/express", "^4.17.13"), +("@types/express-serve-static-core", "^4.17.13"), +("@types/node", "^18.11.9"), +("@types/react", "^18.0.37"), +("@types/react-dom", "^18.0.11"), +("@types/react-router-dom", "^5.3.3"), +("@types/uuid", "^9.0.0"), +("@vitejs/plugin-react-swc", "^3.0.0"), +("dotenv", "^16.0.3"), // duplicate +("nodemon", "^2.0.19"), // +("prisma", show prismaVersion), // +("standard", "^17.0.0"), // +("typescript", "^5.1.0"), // +("vite", "^4.3.9"), // + +Their package.json: +("react", "^18.2.0"), +("typescript", "^5.1.0") + + +Server + +("cookie-parser", "~1.4.6"), +- [Framework] Generator/templates/server/src/middleware/globalMiddleware.ts + +("cors", "^2.8.5"), +- [Framework] Generator/templates/server/src/middleware/globalMiddleware.ts + +("express", "~4.18.1"), +- Generator/templates/server/src/auth/providers/config/local.ts +- Generator/templates/server/src/auth/providers/config/email.ts +- Generator/templates/server/src/routes/crud/index.ts +- Generator/templates/server/src/routes/crud/_crud.ts +- Generator/templates/server/src/routes/operations/index.js +- Generator/templates/server/src/routes/index.js +- Generator/templates/server/src/auth/providers/index.ts +- Generator/templates/server/src/auth/providers/oauth/createRouter.ts +- Generator/templates/server/src/routes/apis/index.ts +- Generator/templates/server/src/auth/providers/types.ts +- Generator/templates/server/src/types/index.ts +- Generator/templates/server/src/middleware/globalMiddleware.ts +- Generator/templates/server/src/app.js +- Generator/templates/server/src/auth/providers/email/signup.ts +- Generator/templates/server/src/routes/auth/index.js +- Generator/templates/server/src/auth/providers/email/login.ts +- Generator/templates/server/src/auth/providers/email/resetPassword.ts +- Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts +- Generator/templates/server/src/auth/providers/email/verifyEmail.ts +- Generator/templates/server/src/_types/index.ts +- Generator/templates/server/src/apis/types.ts + +("morgan", "~1.10.0"), +- [Framework] Generator/templates/server/src/middleware/globalMiddleware.ts + +("@prisma/client", show prismaVersion), +- [SDK] Generator/templates/react-app/src/entities/index.ts +- [SDK] Generator/templates/server/src/dbClient.ts +- [Framework] Generator/templates/server/src/utils.js +- Generator/templates/server/src/auth/utils.ts +- Generator/templates/server/src/entities/index.ts +- Generator/templates/server/src/auth/providers/oauth/types.ts +- Generator/templates/server/src/crud/_operations.ts +- Generator/templates/server/src/dbSeed/types.ts + + +("jsonwebtoken", "^8.5.1"), +-- NOTE: secure-password has a package.json override for sodium-native. +("secure-password", "^4.0.0"), +("dotenv", "16.0.2"), +("helmet", "^6.0.0"), +("patch-package", "^6.4.7"), +("uuid", "^9.0.0"), +("lodash.merge", "^4.6.2"), +("rate-limiter-flexible", "^2.4.1"), +("superjson", "^1.12.2") + +depsRequiredByPassport spec + +depsRequiredByJobs spec + +depsRequiredByEmail spec + +depsRequiredByWebSockets spec, + N.waspDevDependencies = + AS.Dependency.fromList + [ ("nodemon", "^2.0.19"), + ("standard", "^17.0.0"), + ("prisma", show prismaVersion), + -- TODO: Allow users to choose whether they want to use TypeScript + -- in their projects and install these dependencies accordingly. + ("typescript", "^5.1.0"), + ("@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"), + ("@types/uuid", "^9.0.0"), + ("@types/cors", "^2.8.5") + ] + } diff --git a/waspc/data/Generator/templates/sdk/package.json b/waspc/data/Generator/templates/sdk/package.json new file mode 100644 index 0000000000..320be0c4bd --- /dev/null +++ b/waspc/data/Generator/templates/sdk/package.json @@ -0,0 +1,36 @@ +{{={= =}=}} +{ + "name": "wasp", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "types": "tsc --declaration --emitDeclarationOnly --stripInternal --declarationDir dist" + }, + "exports": { + "./core/HttpError": "./core/HttpError.js", + "./core/AuthError": "./core/AuthError.js", + "./core/config": "./core/config.js", + "./core/stitches.config": "./core/stitches.config.js", + "./core/storage": "./core/storage.ts", + "./rpc": "./rpc/index.ts", + "./rpc/queries": "./rpc/queries/index.ts", + "./rpc/actions": "./rpc/actions/index.ts", + "./rpc/queryClient": "./rpc/queryClient.ts", + "./types": "./types/index.ts", + "./auth/*": "./auth/*", + "./api": "./api/index.ts", + "./api/*": "./api/*", + "./operations": "./operations/index.ts", + "./operations/*": "./operations/*", + "./universal/url": "./universal/url.ts", + "./universal/types": "./universal/url.ts" + }, + "license": "ISC", + "include": [ + "src/**/*" + ], + {=& depsChunk =}, + {=& devDepsChunk =} +} diff --git a/waspc/data/Generator/templates/sdk/wasp/api/events.ts b/waspc/data/Generator/templates/sdk/wasp/api/events.ts new file mode 100644 index 0000000000..a72e48dda8 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/api/events.ts @@ -0,0 +1,11 @@ +import mitt, { Emitter } from 'mitt'; + +type ApiEvents = { + // key: Event name + // type: Event payload type + 'sessionId.set': void; + 'sessionId.clear': void; +}; + +// Used to allow API clients to register for auth session ID change events. +export const apiEventsEmitter: Emitter = mitt(); diff --git a/waspc/data/Generator/templates/sdk/wasp/api/index.ts b/waspc/data/Generator/templates/sdk/wasp/api/index.ts new file mode 100644 index 0000000000..8b22dd7ebc --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/api/index.ts @@ -0,0 +1,104 @@ +import axios, { type AxiosError } from 'axios' + +import config from 'wasp/core/config' +import { storage } from 'wasp/core/storage' +import { apiEventsEmitter } from 'wasp/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 diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/forms/Auth.tsx b/waspc/data/Generator/templates/sdk/wasp/auth/forms/Auth.tsx new file mode 100644 index 0000000000..92c58131f6 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/forms/Auth.tsx @@ -0,0 +1,85 @@ +import { useState, createContext } from 'react' +import { createTheme } from '@stitches/react' +import { styled } from 'wasp/core/stitches.config' + +import { + type State, + type CustomizationOptions, + type ErrorMessage, + type AdditionalSignupFields, +} from './types' +import { LoginSignupForm } from './internal/common/LoginSignupForm' +import { MessageError, MessageSuccess } from './internal/Message' + +const logoStyle = { + height: '3rem' +} + +const Container = styled('div', { + display: 'flex', + flexDirection: 'column', +}) + +const HeaderText = styled('h2', { + fontSize: '1.875rem', + fontWeight: '700', + marginTop: '1.5rem' +}) + + +export const AuthContext = createContext({ + isLoading: false, + setIsLoading: (isLoading: boolean) => {}, + setErrorMessage: (errorMessage: ErrorMessage | null) => {}, + setSuccessMessage: (successMessage: string | null) => {}, +}) + +function Auth ({ state, appearance, logo, socialLayout = 'horizontal', additionalSignupFields }: { + state: State; +} & CustomizationOptions & { + additionalSignupFields?: AdditionalSignupFields; +}) { + const [errorMessage, setErrorMessage] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // TODO(matija): this is called on every render, is it a problem? + // If we do it in useEffect(), then there is a glitch between the default color and the + // user provided one. + const customTheme = createTheme(appearance ?? {}) + + const titles: Record = { + login: 'Log in to your account', + signup: 'Create a new account', + } + const title = titles[state] + + const socialButtonsDirection = socialLayout === 'vertical' ? 'vertical' : 'horizontal' + + return ( + +
+ {logo && (Your Company)} + {title} +
+ + {errorMessage && ( + + {errorMessage.title}{errorMessage.description && ': '}{errorMessage.description} + + )} + {successMessage && {successMessage}} + + {(state === 'login' || state === 'signup') && ( + + )} + +
+ ) +} + +export default Auth; diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/forms/Login.tsx b/waspc/data/Generator/templates/sdk/wasp/auth/forms/Login.tsx new file mode 100644 index 0000000000..2ea532d9c5 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/forms/Login.tsx @@ -0,0 +1,17 @@ +import Auth from './Auth' +import { type CustomizationOptions, State } from './types' + +export function LoginForm({ + appearance, + logo, + socialLayout, +}: CustomizationOptions) { + return ( + + ) +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/forms/Signup.tsx b/waspc/data/Generator/templates/sdk/wasp/auth/forms/Signup.tsx new file mode 100644 index 0000000000..66ffab4503 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/forms/Signup.tsx @@ -0,0 +1,23 @@ +import Auth from './Auth' +import { + type CustomizationOptions, + type AdditionalSignupFields, + State, +} from './types' + +export function SignupForm({ + appearance, + logo, + socialLayout, + additionalFields, +}: CustomizationOptions & { additionalFields?: AdditionalSignupFields; }) { + return ( + + ) +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/Form.tsx b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/Form.tsx new file mode 100644 index 0000000000..781c75a0ae --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/Form.tsx @@ -0,0 +1,95 @@ +import { styled } from 'wasp/core/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', +}) diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/Message.tsx b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/Message.tsx new file mode 100644 index 0000000000..7279ed2525 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/Message.tsx @@ -0,0 +1,18 @@ +import { styled } from 'wasp/core/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', +}) diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx new file mode 100644 index 0000000000..30665b4759 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx @@ -0,0 +1,178 @@ +import { useContext } from 'react' +import { useForm, UseFormReturn } from 'react-hook-form' +import { styled } from 'wasp/core/stitches.config' +import config from 'wasp/core/config' + +import { AuthContext } from '../../Auth' +import { + Form, + FormInput, + FormItemGroup, + FormLabel, + FormError, + FormTextarea, + SubmitButton, +} from '../Form' +import type { + AdditionalSignupFields, + AdditionalSignupField, + AdditionalSignupFieldRenderFn, + FormState, +} from '../../types' +import { useHistory } from 'react-router-dom' +import { useUsernameAndPassword } from '../usernameAndPassword/useUsernameAndPassword' + + +export type LoginSignupFormFields = { + [key: string]: string; +} + +export const LoginSignupForm = ({ + state, + socialButtonsDirection = 'horizontal', + additionalSignupFields, +}: { + state: 'login' | 'signup' + socialButtonsDirection?: 'horizontal' | 'vertical' + additionalSignupFields?: AdditionalSignupFields +}) => { + const { + isLoading, + setErrorMessage, + setSuccessMessage, + setIsLoading, + } = useContext(AuthContext) + const isLogin = state === 'login' + const cta = isLogin ? 'Log in' : 'Sign up'; + const history = useHistory(); + const onErrorHandler = (error) => { + setErrorMessage({ title: error.message, description: error.data?.data?.message }) + }; + const hookForm = useForm() + const { register, formState: { errors }, handleSubmit: hookFormHandleSubmit } = hookForm + const { handleSubmit } = useUsernameAndPassword({ + isLogin, + onError: onErrorHandler, + onSuccess() { + history.push('/') + }, + }); + async function onSubmit (data) { + setIsLoading(true); + setErrorMessage(null); + setSuccessMessage(null); + try { + await handleSubmit(data); + } finally { + setIsLoading(false); + } + } + + return (<> +
+ + Username + + {errors.username && {errors.username.message}} + + + Password + + {errors.password && {errors.password.message}} + + + + {cta} + + + ) +} + +function AdditionalFormFields({ + hookForm, + formState: { isLoading }, + additionalSignupFields, +}: { + hookForm: UseFormReturn; + formState: FormState; + additionalSignupFields: AdditionalSignupFields; +}) { + const { + register, + formState: { errors }, + } = hookForm; + + function renderField>( + field: AdditionalSignupField, + // Ideally we would use ComponentType here, but it doesn't work with react-hook-form + Component: any, + props?: React.ComponentProps + ) { + return ( + + {field.label} + + {errors[field.name] && ( + {errors[field.name].message} + )} + + ); + } + + if (areAdditionalFieldsRenderFn(additionalSignupFields)) { + return additionalSignupFields(hookForm, { isLoading }) + } + + return ( + additionalSignupFields && + additionalSignupFields.map((field) => { + if (isFieldRenderFn(field)) { + return field(hookForm, { isLoading }) + } + switch (field.type) { + case 'input': + return renderField(field, FormInput, { + type: 'text', + }) + case 'textarea': + return renderField(field, FormTextarea) + default: + throw new Error( + `Unsupported additional signup field type: ${field.type}` + ) + } + }) + ) +} + +function isFieldRenderFn( + additionalSignupField: AdditionalSignupField | AdditionalSignupFieldRenderFn +): additionalSignupField is AdditionalSignupFieldRenderFn { + return typeof additionalSignupField === 'function' +} + +function areAdditionalFieldsRenderFn( + additionalSignupFields: AdditionalSignupFields +): additionalSignupFields is AdditionalSignupFieldRenderFn { + return typeof additionalSignupFields === 'function' +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/usernameAndPassword/useUsernameAndPassword.ts b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/usernameAndPassword/useUsernameAndPassword.ts new file mode 100644 index 0000000000..247c1faeb4 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/usernameAndPassword/useUsernameAndPassword.ts @@ -0,0 +1,29 @@ +import signup from '../../../signup' +import login from '../../../login' + +export function useUsernameAndPassword({ + onError, + onSuccess, + isLogin, +}: { + onError: (error: Error) => void + onSuccess: () => void + isLogin: boolean +}) { + async function handleSubmit(data) { + try { + if (!isLogin) { + await signup(data) + } + await login(data.username, data.password) + + onSuccess() + } catch (err: unknown) { + onError(err as Error) + } + } + + return { + handleSubmit, + } +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/forms/types.ts b/waspc/data/Generator/templates/sdk/wasp/auth/forms/types.ts new file mode 100644 index 0000000000..14d61ad51e --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/forms/types.ts @@ -0,0 +1,39 @@ +import { createTheme } from '@stitches/react' +import { UseFormReturn, RegisterOptions } from 'react-hook-form' +import type { LoginSignupFormFields } from './internal/common/LoginSignupForm' + +export enum State { + Login = 'login', + Signup = 'signup', +} + +export type CustomizationOptions = { + logo?: string + socialLayout?: 'horizontal' | 'vertical' + appearance?: Parameters[0] +} + +export type ErrorMessage = { + title: string + description?: string +} + +export type FormState = { + isLoading: boolean +} + +export type AdditionalSignupFieldRenderFn = ( + hookForm: UseFormReturn, + formState: FormState +) => React.ReactNode + +export type AdditionalSignupField = { + name: string + label: string + type: 'input' | 'textarea' + validations?: RegisterOptions +} + +export type AdditionalSignupFields = + | (AdditionalSignupField | AdditionalSignupFieldRenderFn)[] + | AdditionalSignupFieldRenderFn diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/helpers/user.ts b/waspc/data/Generator/templates/sdk/wasp/auth/helpers/user.ts new file mode 100644 index 0000000000..498f2588a8 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/helpers/user.ts @@ -0,0 +1,14 @@ +import { setSessionId } from 'wasp/api' +import { invalidateAndRemoveQueries } from 'wasp/operations/resources' + +export async function initSession(sessionId: string): Promise { + 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() +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/jwt.ts b/waspc/data/Generator/templates/sdk/wasp/auth/jwt.ts new file mode 100644 index 0000000000..06c0f10d3a --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/jwt.ts @@ -0,0 +1,12 @@ +import jwt from 'jsonwebtoken' +import util from 'util' + +import config from 'wasp/core/config' + +const jwtSign = util.promisify(jwt.sign) +const jwtVerify = util.promisify(jwt.verify) + +const JWT_SECRET = config.auth.jwtSecret + +export const signData = (data, options) => jwtSign(data, JWT_SECRET, options) +export const verify = (token) => jwtVerify(token, JWT_SECRET) diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/login.ts b/waspc/data/Generator/templates/sdk/wasp/auth/login.ts new file mode 100644 index 0000000000..2b4ec4b9fe --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/login.ts @@ -0,0 +1,13 @@ +import api, { handleApiError } from 'wasp/api' +import { initSession } from './helpers/user' + +export default async function login(username: string, password: string): Promise { + try { + const args = { username, password } + const response = await api.post('/auth/username/login', args) + + await initSession(response.data.sessionId) + } catch (error) { + handleApiError(error) + } +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/logout.ts b/waspc/data/Generator/templates/sdk/wasp/auth/logout.ts new file mode 100644 index 0000000000..cc41b6989c --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/logout.ts @@ -0,0 +1,17 @@ +import api, { removeLocalUserData } from 'wasp/api' +import { invalidateAndRemoveQueries } from 'wasp/operations/resources' + +export default async function logout(): Promise { + 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() + } +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/lucia.ts b/waspc/data/Generator/templates/sdk/wasp/auth/lucia.ts new file mode 100644 index 0000000000..690d090d44 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/lucia.ts @@ -0,0 +1,55 @@ +import { Lucia } from "lucia"; +import { PrismaAdapter } from "@lucia-auth/adapter-prisma"; +import prisma from '../server/dbClient.js' +import config from 'wasp/core/config' +import { type User } from "../entities/index.js" + +const prismaAdapter = new PrismaAdapter( + // Using `as any` here since Lucia's model types are not compatible with Prisma 4 + // model types. This is a temporary workaround until we migrate to Prisma 5. + // This **works** in runtime, but Typescript complains about it. + prisma.session as any, + prisma.auth as any +); + +/** + * We are using Lucia for session management. + * + * Some details: + * 1. We are using the Prisma adapter for Lucia. + * 2. We are not using cookies for session management. Instead, we are using + * the Authorization header to send the session token. + * 3. Our `Session` entity is connected to the `Auth` entity. + * 4. We are exposing the `userId` field from the `Auth` entity to + * make fetching the User easier. + */ +export const auth = new Lucia<{}, { + userId: User['id'] +}>(prismaAdapter, { + // Since we are not using cookies, we don't need to set any cookie options. + // But in the future, if we decide to use cookies, we can set them here. + + // sessionCookie: { + // name: "session", + // expires: true, + // attributes: { + // secure: !config.isDevelopment, + // sameSite: "lax", + // }, + // }, + getUserAttributes({ userId }) { + return { + userId, + }; + }, +}); + +declare module "lucia" { + interface Register { + Lucia: typeof auth; + DatabaseSessionAttributes: {}; + DatabaseUserAttributes: { + userId: User['id'] + }; + } +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/pages/createAuthRequiredPage.jsx b/waspc/data/Generator/templates/sdk/wasp/auth/pages/createAuthRequiredPage.jsx new file mode 100644 index 0000000000..621ef393d9 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/pages/createAuthRequiredPage.jsx @@ -0,0 +1,30 @@ +import React from 'react' + +import { Redirect } from 'react-router-dom' +import useAuth from '../useAuth' + + +const createAuthRequiredPage = (Page) => { + return (props) => { + const { data: user, isError, isSuccess, isLoading } = useAuth() + + if (isSuccess) { + if (user) { + return ( + + ) + } else { + return + } + } else if (isLoading) { + return Loading... + } else if (isError) { + return An error ocurred. Please refresh the page. + } else { + return An unknown error ocurred. Please refresh the page. + } + } +} + +export default createAuthRequiredPage + diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/password.ts b/waspc/data/Generator/templates/sdk/wasp/auth/password.ts new file mode 100644 index 0000000000..a359892b5e --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/password.ts @@ -0,0 +1,15 @@ +import SecurePassword from 'secure-password' + +const SP = new SecurePassword() + +export const hashPassword = async (password: string): Promise => { + const hashedPwdBuffer = await SP.hash(Buffer.from(password)) + return hashedPwdBuffer.toString("base64") +} + +export const verifyPassword = async (hashedPassword: string, password: string): Promise => { + const result = await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64")) + if (result !== SecurePassword.VALID) { + throw new Error('Invalid password.') + } +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/providers/types.ts b/waspc/data/Generator/templates/sdk/wasp/auth/providers/types.ts new file mode 100644 index 0000000000..76e1114850 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/providers/types.ts @@ -0,0 +1,40 @@ +import type { Router, Request } from 'express' +import type { Prisma } from '@prisma/client' +import type { Expand } from 'wasp/universal/types' +import type { ProviderName } from '../utils' + +type UserEntityCreateInput = Prisma.UserCreateInput + +export type ProviderConfig = { + // Unique provider identifier, used as part of URL paths + id: ProviderName; + displayName: string; + // Each provider config can have an init method which is ran on setup time + // e.g. for oAuth providers this is the time when the Passport strategy is registered. + init?(provider: ProviderConfig): Promise; + // Every provider must have a setupRouter method which returns the Express router. + // In this function we are flexibile to do what ever is necessary to make the provider work. + createRouter(provider: ProviderConfig, initData: InitData): Router; +}; + +export type InitData = { + [key: string]: any; +} + +export type RequestWithWasp = Request & { wasp?: { [key: string]: any } } + +export type PossibleUserFields = Expand> + +export type UserSignupFields = { + [key in keyof PossibleUserFields]: FieldGetter< + PossibleUserFields[key] + > +} + +type FieldGetter = ( + data: { [key: string]: unknown } +) => Promise | T | undefined + +export function defineUserSignupFields(fields: UserSignupFields) { + return fields +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/session.ts b/waspc/data/Generator/templates/sdk/wasp/auth/session.ts new file mode 100644 index 0000000000..ed9154120b --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/session.ts @@ -0,0 +1,107 @@ +import { Request as ExpressRequest } from "express"; + +import { type User } from "../entities/index.js" +import { type SanitizedUser } from '../server/_types/index.js' + +import { auth } from "./lucia.js"; +import type { Session } from "lucia"; +import { + throwInvalidCredentialsError, + deserializeAndSanitizeProviderData, +} from "./utils.js"; + +import prisma from '../server/dbClient.js' + +// Creates a new session for the `authId` in the database +export async function createSession(authId: string): Promise { + return auth.createSession(authId, {}); +} + +export async function getSessionAndUserFromBearerToken(req: ExpressRequest): Promise<{ + user: SanitizedUser | null, + session: Session | null, +}> { + const authorizationHeader = req.headers["authorization"]; + + if (typeof authorizationHeader !== "string") { + return { + user: null, + session: null, + }; + } + + const sessionId = auth.readBearerToken(authorizationHeader); + if (!sessionId) { + return { + user: null, + session: null, + }; + } + + return getSessionAndUserFromSessionId(sessionId); +} + +export async function getSessionAndUserFromSessionId(sessionId: string): Promise<{ + user: SanitizedUser | null, + session: Session | null, +}> { + const { session, user: authEntity } = await auth.validateSession(sessionId); + + if (!session || !authEntity) { + return { + user: null, + session: null, + }; + } + + return { + session, + user: await getUser(authEntity.userId) + } +} + +async function getUser(userId: User['id']): Promise { + const user = await prisma.user + .findUnique({ + where: { id: userId }, + include: { + auth: { + include: { + identities: true + } + } + } + }) + + 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 deserializedIdentities = user.auth.identities.map((identity) => { + const deserializedProviderData = deserializeAndSanitizeProviderData( + identity.providerData, + { + shouldRemovePasswordField: true, + } + ) + return { + ...identity, + providerData: deserializedProviderData, + } + }) + return { + ...user, + auth: { + ...user.auth, + identities: deserializedIdentities, + }, + } +} + +export function invalidateSession(sessionId: string): Promise { + return auth.invalidateSession(sessionId); +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/signup.ts b/waspc/data/Generator/templates/sdk/wasp/auth/signup.ts new file mode 100644 index 0000000000..bde50c5ebd --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/signup.ts @@ -0,0 +1,9 @@ +import api, { handleApiError } from 'wasp/api' + +export default async function signup(userFields: { username: string; password: string }): Promise { + try { + await api.post('/auth/username/signup', userFields) + } catch (error) { + handleApiError(error) + } +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/types.ts b/waspc/data/Generator/templates/sdk/wasp/auth/types.ts new file mode 100644 index 0000000000..f9f079a57a --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/types.ts @@ -0,0 +1,2 @@ +// todo(filip): turn into a proper import/path +export type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from 'wasp/server/_types/' diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/useAuth.ts b/waspc/data/Generator/templates/sdk/wasp/auth/useAuth.ts new file mode 100644 index 0000000000..29b95f62a0 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/useAuth.ts @@ -0,0 +1,38 @@ +import { deserialize as superjsonDeserialize } from 'superjson' +import { useQuery } from 'wasp/rpc' +import api, { handleApiError } from 'wasp/api' +import { HttpMethod } from 'wasp/types' +import type { User } from './types' +import { addMetadataToQuery } from 'wasp/rpc/queries' + +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 { + 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: ['User'], + }) + + return getMe +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/user.ts b/waspc/data/Generator/templates/sdk/wasp/auth/user.ts new file mode 100644 index 0000000000..aa0da24824 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/user.ts @@ -0,0 +1,27 @@ +// 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 + ); +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/utils.ts b/waspc/data/Generator/templates/sdk/wasp/auth/utils.ts new file mode 100644 index 0000000000..603e9a4b11 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/utils.ts @@ -0,0 +1,302 @@ +import { hashPassword } from './password.js' +import { verify } from './jwt.js' +import AuthError from '../core/AuthError.js' +import HttpError from '../core/HttpError.js' +import prisma from '../server/dbClient.js' +import { sleep } from '../server/utils' +import { + type User, + type Auth, + type AuthIdentity, +} from '../entities' +import { Prisma } from '@prisma/client'; + +import { throwValidationError } from './validation.js' + +import { type UserSignupFields, type PossibleUserFields } from './providers/types.js' + +export type EmailProviderData = { + hashedPassword: string; + isEmailVerified: boolean; + emailVerificationSentAt: string | null; + passwordResetSentAt: string | null; +} + +export type UsernameProviderData = { + hashedPassword: string; +} + +export type OAuthProviderData = {} + +/** + * This type is used for type-level programming e.g. to enumerate + * all possible provider data types. + * + * The keys of this type are the names of the providers and the values + * are the types of the provider data. + */ +export type PossibleProviderData = { + email: EmailProviderData; + username: UsernameProviderData; + google: OAuthProviderData; + github: OAuthProviderData; +} + +export type ProviderName = keyof PossibleProviderData + +export const contextWithUserEntity = { + entities: { + User: prisma.user + } +} + +export const authConfig = { + failureRedirectPath: "/login", + successRedirectPath: "/", +} + +/** + * ProviderId uniquely identifies an auth identity e.g. + * "email" provider with user id "test@test.com" or + * "google" provider with user id "1234567890". + * + * We use this type to avoid passing the providerName and providerUserId + * separately. Also, we can normalize the providerUserId to make sure it's + * consistent across different DB operations. + */ +export type ProviderId = { + providerName: ProviderName; + providerUserId: string; +} + +export function createProviderId(providerName: ProviderName, providerUserId: string): ProviderId { + return { + providerName, + providerUserId: providerUserId.toLowerCase(), + } +} + +export async function findAuthIdentity(providerId: ProviderId): Promise { + return prisma.authIdentity.findUnique({ + where: { + providerName_providerUserId: providerId, + } + }); +} + +/** + * Updates the provider data for the given auth identity. + * + * This function performs data sanitization and serialization. + * Sanitization is done by hashing the password, so this function + * expects the password received in the `providerDataUpdates` + * **not to be hashed**. + */ +export async function updateAuthIdentityProviderData( + providerId: ProviderId, + existingProviderData: PossibleProviderData[PN], + providerDataUpdates: Partial, +): Promise { + // We are doing the sanitization here only on updates to avoid + // hashing the password multiple times. + const sanitizedProviderDataUpdates = await sanitizeProviderData(providerDataUpdates); + const newProviderData = { + ...existingProviderData, + ...sanitizedProviderDataUpdates, + } + const serializedProviderData = await serializeProviderData(newProviderData); + return prisma.authIdentity.update({ + where: { + providerName_providerUserId: providerId, + }, + data: { providerData: serializedProviderData }, + }); +} + +type FindAuthWithUserResult = Auth & { + user: User +} + +export async function findAuthWithUserBy( + where: Prisma.AuthWhereInput +): Promise { + return prisma.auth.findFirst({ where, include: { user: true }}); +} + +export async function createUser( + providerId: ProviderId, + serializedProviderData?: string, + userFields?: PossibleUserFields, +): Promise { + return prisma.user.create({ + data: { + // Using any here to prevent type errors when userFields are not + // defined. We want Prisma to throw an error in that case. + ...(userFields ?? {} as any), + auth: { + create: { + identities: { + create: { + providerName: providerId.providerName, + providerUserId: providerId.providerUserId, + providerData: serializedProviderData, + }, + }, + } + }, + }, + // We need to include the Auth entity here because we need `authId` + // to be able to create a session. + include: { + auth: true, + }, + }) +} + +export async function deleteUserByAuthId(authId: string): Promise<{ count: number }> { + return prisma.user.deleteMany({ where: { auth: { + id: authId, + } } }) +} + +export async function verifyToken(token: string): Promise { + return verify(token); +} + +// If an user exists, we don't want to leak information +// about it. Pretending that we're doing some work +// will make it harder for an attacker to determine +// if a user exists or not. +// NOTE: Attacker measuring time to response can still determine +// if a user exists or not. We'll be able to avoid it when +// we implement e-mail sending via jobs. +export async function doFakeWork(): Promise { + const timeToWork = Math.floor(Math.random() * 1000) + 1000; + return sleep(timeToWork); +} + +export function rethrowPossibleAuthError(e: unknown): void { + if (e instanceof AuthError) { + throwValidationError(e.message); + } + + // Prisma code P2002 is for unique constraint violations. + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new HttpError(422, 'Save failed', { + message: `user with the same identity already exists`, + }) + } + + if (e instanceof Prisma.PrismaClientValidationError) { + // NOTE: Logging the error since this usually means that there are + // required fields missing in the request, we want the developer + // to know about it. + console.error(e) + throw new HttpError(422, 'Save failed', { + message: 'there was a database error' + }) + } + + // Prisma code P2021 is for missing table errors. + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2021') { + // NOTE: Logging the error since this usually means that the database + // migrations weren't run, we want the developer to know about it. + console.error(e) + console.info('🐝 This error can happen if you did\'t run the database migrations.') + throw new HttpError(500, 'Save failed', { + message: `there was a database error`, + }) + } + + // Prisma code P2003 is for foreign key constraint failure + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2003') { + console.error(e) + console.info(`🐝 This error can happen if you have some relation on your User entity + 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`) + throw new HttpError(500, 'Save failed', { + message: `there was a database error`, + }) + } + + throw e +} + +export async function validateAndGetUserFields( + data: { + [key: string]: unknown + }, + userSignupFields?: UserSignupFields, +): Promise> { + const { + password: _password, + ...sanitizedData + } = data; + const result: Record = {}; + + if (!userSignupFields) { + return result; + } + + for (const [field, getFieldValue] of Object.entries(userSignupFields)) { + try { + const value = await getFieldValue(sanitizedData) + result[field] = value + } catch (e) { + throwValidationError(e.message) + } + } + return result; +} + +export function deserializeAndSanitizeProviderData( + providerData: string, + { shouldRemovePasswordField = false }: { shouldRemovePasswordField?: boolean } = {}, +): PossibleProviderData[PN] { + // NOTE: We are letting JSON.parse throw an error if the providerData is not valid JSON. + let data = JSON.parse(providerData) as PossibleProviderData[PN]; + + if (providerDataHasPasswordField(data) && shouldRemovePasswordField) { + delete data.hashedPassword; + } + + return data; +} + +export async function sanitizeAndSerializeProviderData( + providerData: PossibleProviderData[PN], +): Promise { + return serializeProviderData( + await sanitizeProviderData(providerData) + ); +} + +function serializeProviderData(providerData: PossibleProviderData[PN]): string { + return JSON.stringify(providerData); +} + +async function sanitizeProviderData( + providerData: PossibleProviderData[PN], +): Promise { + const data = { + ...providerData, + }; + if (providerDataHasPasswordField(data)) { + data.hashedPassword = await hashPassword(data.hashedPassword); + } + + return data; +} + + +function providerDataHasPasswordField( + providerData: PossibleProviderData[keyof PossibleProviderData], +): providerData is { hashedPassword: string } { + return 'hashedPassword' in providerData; +} + +export function throwInvalidCredentialsError(message?: string): void { + throw new HttpError(401, 'Invalid credentials', { message }) +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/validation.ts b/waspc/data/Generator/templates/sdk/wasp/auth/validation.ts new file mode 100644 index 0000000000..f384a28c87 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/validation.ts @@ -0,0 +1,77 @@ +import HttpError from '../core/HttpError.js'; + +export const PASSWORD_FIELD = 'password'; +const USERNAME_FIELD = 'username'; +const EMAIL_FIELD = 'email'; +const TOKEN_FIELD = 'token'; + +export function ensureValidEmail(args: unknown): void { + validate(args, [ + { validates: EMAIL_FIELD, message: 'email must be present', validator: email => !!email }, + { validates: EMAIL_FIELD, message: 'email must be a valid email', validator: email => isValidEmail(email) }, + ]); +} + +export function ensureValidUsername(args: unknown): void { + validate(args, [ + { validates: USERNAME_FIELD, message: 'username must be present', validator: username => !!username } + ]); +} + +export function ensurePasswordIsPresent(args: unknown): void { + validate(args, [ + { validates: PASSWORD_FIELD, message: 'password must be present', validator: password => !!password }, + ]); +} + +export function ensureValidPassword(args: unknown): void { + validate(args, [ + { validates: PASSWORD_FIELD, message: 'password must be at least 8 characters', validator: password => isMinLength(password, 8) }, + { validates: PASSWORD_FIELD, message: 'password must contain a number', validator: password => containsNumber(password) }, + ]); +} + +export function ensureTokenIsPresent(args: unknown): void { + validate(args, [ + { validates: TOKEN_FIELD, message: 'token must be present', validator: token => !!token }, + ]); +} + +export function throwValidationError(message: string): void { + throw new HttpError(422, 'Validation failed', { message }) +} + +function validate(args: unknown, validators: { validates: string, message: string, validator: (value: unknown) => boolean }[]): void { + for (const { validates, message, validator } of validators) { + if (!validator(args[validates])) { + throwValidationError(message); + } + } +} + +// NOTE(miho): it would be good to replace our custom validations with e.g. Zod + +const validEmailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/ +function isValidEmail(input: unknown): boolean { + if (typeof input !== 'string') { + return false + } + + return input.match(validEmailRegex) !== null +} + +function isMinLength(input: unknown, minLength: number): boolean { + if (typeof input !== 'string') { + return false + } + + return input.length >= minLength +} + +function containsNumber(input: unknown): boolean { + if (typeof input !== 'string') { + return false + } + + return /\d/.test(input) +} diff --git a/waspc/data/Generator/templates/server/src/core/AuthError.js b/waspc/data/Generator/templates/sdk/wasp/core/AuthError.js similarity index 100% rename from waspc/data/Generator/templates/server/src/core/AuthError.js rename to waspc/data/Generator/templates/sdk/wasp/core/AuthError.js diff --git a/waspc/data/Generator/templates/server/src/core/HttpError.js b/waspc/data/Generator/templates/sdk/wasp/core/HttpError.js similarity index 100% rename from waspc/data/Generator/templates/server/src/core/HttpError.js rename to waspc/data/Generator/templates/sdk/wasp/core/HttpError.js diff --git a/waspc/data/Generator/templates/sdk/wasp/core/auth.js b/waspc/data/Generator/templates/sdk/wasp/core/auth.js new file mode 100644 index 0000000000..6908bfb517 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/core/auth.js @@ -0,0 +1,41 @@ +import { randomInt } from 'node:crypto' + +import prisma from '../server/dbClient.js' +import { handleRejection } from '../utils.js' +import { getSessionAndUserFromBearerToken } from 'wasp/auth/session' +import { throwInvalidCredentialsError } from 'wasp/auth/utils' + +/** + * Auth middleware + * + * If the request includes an `Authorization` header it will try to authenticate the request, + * otherwise it will let the request through. + * + * - If authentication succeeds it sets `req.sessionId` and `req.user` + * - `req.user` is the user that made the request and it's used in + * all Wasp features that need to know the user that made the request. + * - `req.sessionId` is the ID of the session that authenticated the request. + * - If the request is not authenticated, it throws an error. + */ +const auth = handleRejection(async (req, res, next) => { + const authHeader = req.get('Authorization') + if (!authHeader) { + // NOTE(matija): for now we let tokenless requests through and make it operation's + // responsibility to verify whether the request is authenticated or not. In the future + // we will develop our own system at Wasp-level for that. + return next() + } + + const { session, user } = await getSessionAndUserFromBearerToken(req); + + if (!session || !user) { + throwInvalidCredentialsError() + } + + req.sessionId = session.id + req.user = user + + next() +}) + +export default auth diff --git a/waspc/data/Generator/templates/sdk/wasp/core/config.js b/waspc/data/Generator/templates/sdk/wasp/core/config.js new file mode 100644 index 0000000000..e9234e6f2a --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/core/config.js @@ -0,0 +1,9 @@ +import { stripTrailingSlash } from 'wasp/universal/url' + +const apiUrl = stripTrailingSlash(import.meta.env.REACT_APP_API_URL) || 'http://localhost:3001'; + +const config = { + apiUrl, +} + +export default config diff --git a/waspc/data/Generator/templates/sdk/wasp/core/stitches.config.js b/waspc/data/Generator/templates/sdk/wasp/core/stitches.config.js new file mode 100644 index 0000000000..c1d600a3f6 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/core/stitches.config.js @@ -0,0 +1,33 @@ +import { createStitches } from '@stitches/react' + +export const { + styled, + css +} = createStitches({ + theme: { + colors: { + waspYellow: '#ffcc00', + gray700: '#a1a5ab', + gray600: '#d1d5db', + gray500: 'gainsboro', + gray400: '#f0f0f0', + red: '#FED7D7', + darkRed: '#fa3838', + green: '#C6F6D5', + + brand: '$waspYellow', + brandAccent: '#ffdb46', + errorBackground: '$red', + errorText: '#2D3748', + successBackground: '$green', + successText: '#2D3748', + + submitButtonText: 'black', + + formErrorText: '$darkRed', + }, + fontSizes: { + sm: '0.875rem' + } + } +}) diff --git a/waspc/data/Generator/templates/sdk/wasp/core/storage.ts b/waspc/data/Generator/templates/sdk/wasp/core/storage.ts new file mode 100644 index 0000000000..0321acea8b --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/core/storage.ts @@ -0,0 +1,50 @@ +export type DataStore = { + 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 { + 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') + +function ensureLocalStorageIsAvailable(): void { + if (!window.localStorage) { + throw new Error('Local storage is not available.') + } +} diff --git a/waspc/data/Generator/templates/sdk/wasp/entities/index.ts b/waspc/data/Generator/templates/sdk/wasp/entities/index.ts new file mode 100644 index 0000000000..5febac3804 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/entities/index.ts @@ -0,0 +1,21 @@ +import { + type User, + type Task, +} from "@prisma/client" + +export { + type User, + type Task, + type Auth, + type AuthIdentity, +} from "@prisma/client" + +export type Entity = + | User + | Task + | never + +export type EntityName = + | "User" + | "Task" + | never diff --git a/waspc/data/Generator/templates/sdk/wasp/ext-src/actions.ts b/waspc/data/Generator/templates/sdk/wasp/ext-src/actions.ts new file mode 100644 index 0000000000..c03bfac62b --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/ext-src/actions.ts @@ -0,0 +1,56 @@ +import HttpError from 'wasp/core/HttpError' +import type { + CreateTask, + UpdateTask, + DeleteTasks, +} from 'wasp/server/actions/types' +import type { Task } from 'wasp/entities' + +type CreateArgs = Pick + +export const createTask: CreateTask = async ( + { description }, + context +) => { + if (!context.user) { + throw new HttpError(401) + } + + return context.entities.Task.create({ + data: { + description, + user: { connect: { id: context.user.id } }, + }, + }) +} + +type UpdateArgs = Pick + +export const updateTask: UpdateTask = async ( + { id, isDone }, + context +) => { + if (!context.user) { + throw new HttpError(401) + } + + return context.entities.Task.update({ + where: { + id, + }, + data: { isDone }, + }) +} + +export const deleteTasks: DeleteTasks = async ( + idsToDelete, + context +) => { + return context.entities.Task.deleteMany({ + where: { + id: { + in: idsToDelete, + }, + }, + }) +} diff --git a/waspc/data/Generator/templates/sdk/wasp/ext-src/queries.ts b/waspc/data/Generator/templates/sdk/wasp/ext-src/queries.ts new file mode 100644 index 0000000000..ac49e0a7a7 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/ext-src/queries.ts @@ -0,0 +1,15 @@ +import HttpError from 'wasp/core/HttpError' +import type { GetTasks } from 'wasp/server/queries/types' +import type { Task } from 'wasp/entities' + +//Using TypeScript's new 'satisfies' keyword, it will infer the types of the arguments and return value +export const getTasks = ((_args, context) => { + if (!context.user) { + throw new HttpError(401) + } + + return context.entities.Task.findMany({ + where: { user: { id: context.user.id } }, + orderBy: { id: 'asc' }, + }) +}) satisfies GetTasks diff --git a/waspc/data/Generator/templates/sdk/wasp/operations/index.ts b/waspc/data/Generator/templates/sdk/wasp/operations/index.ts new file mode 100644 index 0000000000..31e70ae98b --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/operations/index.ts @@ -0,0 +1,22 @@ +import api, { handleApiError } from 'wasp/api' +import { HttpMethod } from 'wasp/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}` } +} diff --git a/waspc/data/Generator/templates/sdk/wasp/operations/resources.js b/waspc/data/Generator/templates/sdk/wasp/operations/resources.js new file mode 100644 index 0000000000..5261654600 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/operations/resources.js @@ -0,0 +1,81 @@ +import { queryClientInitialized } from 'wasp/rpc/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))) +} diff --git a/waspc/data/Generator/templates/sdk/wasp/operations/updateHandlersMap.js b/waspc/data/Generator/templates/sdk/wasp/operations/updateHandlersMap.js new file mode 100644 index 0000000000..8c43c0b1ba --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/operations/updateHandlersMap.js @@ -0,0 +1,37 @@ +export function makeUpdateHandlersMap(calculateHash) { + const updateHandlers = new Map() + + function getHandlerTuples(queryKeyHash) { + return updateHandlers.get(queryKeyHash) || []; + } + + function add(queryKey, updateQuery) { + const queryKeyHash = calculateHash(queryKey) + const handlers = getHandlerTuples(queryKeyHash); + updateHandlers.set(queryKeyHash, [...handlers, { queryKey, updateQuery }]) + } + + function getUpdateHandlers(queryKey) { + const queryKeyHash = calculateHash(queryKey) + return getHandlerTuples(queryKeyHash).map(({ updateQuery }) => updateQuery) + } + + function remove(queryKeyToRemove) { + const queryKeyHash = calculateHash(queryKeyToRemove) + const filteredHandlers = getHandlerTuples(queryKeyHash).filter( + ({ queryKey }) => queryKey !== queryKeyToRemove + ) + + if (filteredHandlers.length > 0) { + updateHandlers.set(queryKeyHash, filteredHandlers) + } else { + updateHandlers.delete(queryKeyHash) + } + } + + return { + add, + remove, + getUpdateHandlers, + } +} diff --git a/waspc/data/Generator/templates/sdk/wasp/rpc/actions/core.d.ts b/waspc/data/Generator/templates/sdk/wasp/rpc/actions/core.d.ts new file mode 100644 index 0000000000..ea41a0eed3 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/rpc/actions/core.d.ts @@ -0,0 +1,13 @@ +import { type Action } from '.' +import type { Expand, _Awaited, _ReturnType } from 'wasp/universal/types' + +export function createAction( + actionRoute: string, + entitiesUsed: unknown[] +): ActionFor + +type ActionFor = Expand< + Action[0], _Awaited<_ReturnType>> +> + +type GenericBackendAction = (args: never, context: any) => unknown diff --git a/waspc/data/Generator/templates/sdk/wasp/rpc/actions/core.js b/waspc/data/Generator/templates/sdk/wasp/rpc/actions/core.js new file mode 100644 index 0000000000..cd1c60ecef --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/rpc/actions/core.js @@ -0,0 +1,37 @@ +import { callOperation, makeOperationRoute } from 'wasp/operations' +import { + registerActionInProgress, + registerActionDone, +} from 'wasp/operations/resources' + +// todo(filip) - turn helpers and core into the same thing + +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 +} diff --git a/waspc/data/Generator/templates/sdk/wasp/rpc/actions/index.ts b/waspc/data/Generator/templates/sdk/wasp/rpc/actions/index.ts new file mode 100644 index 0000000000..2be33b3d65 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/rpc/actions/index.ts @@ -0,0 +1,14 @@ +import { createAction } from './core' +import { CreateTask, UpdateTask } from 'wasp/server/actions' + +export const updateTask = createAction('operations/update-task', [ + 'Task', +]) + +export const createTask = createAction('operations/create-task', [ + 'Task', +]) + +export const deleteTasks = createAction('operations/delete-tasks', [ + 'Task', +]) diff --git a/waspc/data/Generator/templates/sdk/wasp/rpc/index.ts b/waspc/data/Generator/templates/sdk/wasp/rpc/index.ts new file mode 100644 index 0000000000..8a743e3456 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/rpc/index.ts @@ -0,0 +1,338 @@ +import { + QueryClient, + QueryKey, + useMutation, + UseMutationOptions, + useQueryClient, + useQuery as rqUseQuery, + UseQueryResult, +} from "@tanstack/react-query"; +export { configureQueryClient } from "./queryClient"; + +export type Query = { + (queryCacheKey: string[], args: Input): Promise; +}; + +export function useQuery( + queryFn: Query, + queryFnArgs?: Input, + options?: any +): UseQueryResult; + +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, + }); +} + +// todo - turn helpers and core into the same thing + +export type Action = [Input] extends [never] + ? (args?: unknown) => Promise + : (args: Input) => Promise; + +/** + * An options object passed into the `useAction` hook and used to enhance the + * action with extra options. + * + */ +export type ActionOptions = { + optimisticUpdates: OptimisticUpdateDefinition[]; +}; + +/** + * A documented (public) way to define optimistic updates. + */ +export type OptimisticUpdateDefinition = { + getQuerySpecifier: GetQuerySpecifier; + updateQuery: UpdateQuery; +}; + +/** + * A function that takes an item and returns a Wasp Query specifier. + */ +export type GetQuerySpecifier = ( + item: ActionInput +) => QuerySpecifier; + +/** + * 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 = ( + 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/language/features#the-useaction-hook. + */ +export type QuerySpecifier = [Query, ...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( + actionFn: Action, + actionOptions?: ActionOptions +): 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 = { + getQueryKey: (item: ActionInput) => QueryKey; + updateQuery: UpdateQuery; +}; + +/** + * 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 = (oldData: CachedData) => CachedData; + +/** + * A specific, "instantiated" optimistic update definition which contains a + * fully-constructed query key and a specific update function. + */ +type SpecificOptimisticUpdateDefinition = { + queryKey: QueryKey; + updateQuery: SpecificUpdateQuery; +}; + +type InternalAction = Action & { + internal( + item: Input, + optimisticUpdateDefinitions: SpecificOptimisticUpdateDefinition[] + ): Promise; +}; + +/** + * 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/language/features#the-useaction-hook. + * @returns An internally-used optimistic update definition object. + */ +function translateToInternalDefinition( + publicOptimisticUpdateDefinition: OptimisticUpdateDefinition +): InternalOptimisticUpdateDefinition { + 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( + actionFn: Action, + optimisticUpdateDefinitions: InternalOptimisticUpdateDefinition< + Input, + CachedData + >[] +): typeof actionFn { + return function performActionWithOptimisticUpdates(item) { + const specificOptimisticUpdateDefinitions = optimisticUpdateDefinitions.map( + (generalDefinition) => + getOptimisticUpdateDefinitionForSpecificItem(generalDefinition, item) + ); + return (actionFn as InternalAction).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( + queryClient: QueryClient, + optimisticUpdateDefinitions: InternalOptimisticUpdateDefinition< + ActionInput, + CachedData + >[] +): Pick { + 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( + optimisticUpdateDefinition: InternalOptimisticUpdateDefinition< + ActionInput, + CachedData + >, + item: ActionInput +): SpecificOptimisticUpdateDefinition { + 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/language/features#the-useaction-hook. + * @returns A cache key React Query internally uses for addressing queries. + */ +function getRqQueryKeyFromSpecifier( + querySpecifier: QuerySpecifier +): QueryKey { + const [queryFn, ...otherKeys] = querySpecifier; + return [...(queryFn as any).queryCacheKey, ...otherKeys]; +} diff --git a/waspc/data/Generator/templates/sdk/wasp/rpc/queries/core.d.ts b/waspc/data/Generator/templates/sdk/wasp/rpc/queries/core.d.ts new file mode 100644 index 0000000000..ddbb4f2b8e --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/rpc/queries/core.d.ts @@ -0,0 +1,23 @@ +import { type Query } from '..' +import { Route } from 'wasp/types' +import type { Expand, _Awaited, _ReturnType } from 'wasp/universal/types' + +export function createQuery( + queryRoute: string, + entitiesUsed: any[] +): QueryFor + +export function addMetadataToQuery( + query: (...args: any[]) => Promise, + metadata: { + relativeQueryPath: string + queryRoute: Route + entitiesUsed: string[] + } +): void + +type QueryFor = Expand< + Query[0], _Awaited<_ReturnType>> +> + +type GenericBackendQuery = (args: never, context: any) => unknown diff --git a/waspc/data/Generator/templates/sdk/wasp/rpc/queries/core.js b/waspc/data/Generator/templates/sdk/wasp/rpc/queries/core.js new file mode 100644 index 0000000000..616fb82958 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/rpc/queries/core.js @@ -0,0 +1,30 @@ +import { callOperation, makeOperationRoute } from 'wasp/operations' +import { + addResourcesUsedByQuery, + getActiveOptimisticUpdates, +} from 'wasp/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) +} diff --git a/waspc/data/Generator/templates/sdk/wasp/rpc/queries/index.ts b/waspc/data/Generator/templates/sdk/wasp/rpc/queries/index.ts new file mode 100644 index 0000000000..a03221553d --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/rpc/queries/index.ts @@ -0,0 +1,6 @@ +import { createQuery } from './core' +import { GetTasks } from 'wasp/server/queries' + +export const getTasks = createQuery('operations/get-tasks', ['Task']) + +export { addMetadataToQuery } from './core' diff --git a/waspc/data/Generator/templates/sdk/wasp/rpc/queryClient.ts b/waspc/data/Generator/templates/sdk/wasp/rpc/queryClient.ts new file mode 100644 index 0000000000..448be4c5ce --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/rpc/queryClient.ts @@ -0,0 +1,33 @@ +import { QueryClient } from "@tanstack/react-query"; + +type QueryClientConfig = object; + +const defaultQueryClientConfig = {}; + +let queryClientConfig: QueryClientConfig, + resolveQueryClientInitialized: (...args: any[]) => any, + isQueryClientInitialized: boolean; + +export const queryClientInitialized: Promise = new Promise( + (resolve) => { + resolveQueryClientInitialized = resolve; + } +); + +export function configureQueryClient(config: QueryClientConfig): void { + if (isQueryClientInitialized) { + throw new Error( + "Attempted to configure the QueryClient after initialization" + ); + } + + queryClientConfig = config; +} + +export function initializeQueryClient(): void { + const queryClient = new QueryClient( + queryClientConfig ?? defaultQueryClientConfig + ); + isQueryClientInitialized = true; + resolveQueryClientInitialized(queryClient); +} diff --git a/waspc/data/Generator/templates/sdk/wasp/server/_types/index.ts b/waspc/data/Generator/templates/sdk/wasp/server/_types/index.ts new file mode 100644 index 0000000000..4a2afbd4bc --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/server/_types/index.ts @@ -0,0 +1,101 @@ +import { type Expand } from 'wasp/universal/types'; +import { type Request, type Response } from 'express' +import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core' +import prisma from "wasp/server/dbClient" +import { + type User, + type Auth, + type AuthIdentity, +} from "wasp/entities" +import { + type EmailProviderData, + type UsernameProviderData, + type OAuthProviderData, + // todo(filip): marker +} from 'wasp/auth/utils' +import { type _Entity } from "./taggedEntities" +import { type Payload } from "./serialization"; + +export * from "./taggedEntities" +export * from "./serialization" + +export type Query = + Operation + +export type Action = + Operation + +export type AuthenticatedQuery = + AuthenticatedOperation + +export type AuthenticatedAction = + AuthenticatedOperation + +type AuthenticatedOperation = ( + args: Input, + context: ContextWithUser, +) => Output | Promise + +export type AuthenticatedApi< + Entities extends _Entity[], + Params extends ExpressParams, + ResBody, + ReqBody, + ReqQuery extends ExpressQuery, + Locals extends Record +> = ( + req: Request, + res: Response, + context: ContextWithUser, +) => void + +type Operation = ( + args: Input, + context: Context, +) => Output | Promise + +export type Api< + Entities extends _Entity[], + Params extends ExpressParams, + ResBody, + ReqBody, + ReqQuery extends ExpressQuery, + Locals extends Record +> = ( + req: Request, + res: Response, + context: Context, +) => void + +type EntityMap = { + [EntityName in Entities[number]["_entityName"]]: PrismaDelegate[EntityName] +} + +export type PrismaDelegate = { + "User": typeof prisma.user, + "Task": typeof prisma.task, +} + +type Context = Expand<{ + entities: Expand> +}> + +type ContextWithUser = Expand & { user?: SanitizedUser }> + +// TODO: This type must match the logic in auth/session.js (if we remove the +// password field from the object there, we must do the same here). Ideally, +// these two things would live in the same place: +// https://github.com/wasp-lang/wasp/issues/965 + +export type DeserializedAuthIdentity = Expand & { + providerData: Omit | Omit | OAuthProviderData +}> + +export type SanitizedUser = User & { + auth: Auth & { + identities: DeserializedAuthIdentity[] + } | null +} + +// todo(filip): marker +export type { ProviderName } from 'wasp/auth/utils' diff --git a/waspc/data/Generator/templates/sdk/wasp/server/_types/serialization.ts b/waspc/data/Generator/templates/sdk/wasp/server/_types/serialization.ts new file mode 100644 index 0000000000..595b5ba69f --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/server/_types/serialization.ts @@ -0,0 +1,43 @@ +export type Payload = void | SuperJSONValue + +// The part below was copied from SuperJSON and slightly modified: +// https://github.com/blitz-js/superjson/blob/ae7dbcefe5d3ece5b04be0c6afe6b40f3a44a22a/src/types.ts +// +// We couldn't use SuperJSON's types directly because: +// 1. They aren't exported publicly. +// 2. They have a werid quirk that turns `SuperJSONValue` into `any`. +// See why here: +// https://github.com/blitz-js/superjson/pull/36#issuecomment-669239876 +// +// We changed the code as little as possible to make future comparisons easier. +export type JSONValue = PrimitiveJSONValue | JSONArray | JSONObject + +export interface JSONObject { + [key: string]: JSONValue +} + +type PrimitiveJSONValue = string | number | boolean | undefined | null + +interface JSONArray extends Array {} + +type SerializableJSONValue = + | Symbol + | Set + | Map + | undefined + | bigint + | Date + | RegExp + +// Here's where we excluded `ClassInstance` (which was `any`) from the union. +type SuperJSONValue = + | JSONValue + | SerializableJSONValue + | SuperJSONArray + | SuperJSONObject + +interface SuperJSONArray extends Array {} + +interface SuperJSONObject { + [key: string]: SuperJSONValue +} diff --git a/waspc/data/Generator/templates/sdk/wasp/server/_types/taggedEntities.ts b/waspc/data/Generator/templates/sdk/wasp/server/_types/taggedEntities.ts new file mode 100644 index 0000000000..3331b3f893 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/server/_types/taggedEntities.ts @@ -0,0 +1,22 @@ +// Wasp internally uses the types defined in this file for typing entity maps in +// operation contexts. +// +// We must explicitly tag all entities with their name to avoid issues with +// structural typing. See https://github.com/wasp-lang/wasp/pull/982 for details. +import { + type Entity, + type EntityName, + type User, + type Task, +} from '../../entities' + +export type _User = WithName +export type _Task = WithName + +export type _Entity = + | _User + | _Task + | never + +type WithName = + E & { _entityName: Name } diff --git a/waspc/data/Generator/templates/sdk/wasp/server/actions/index.ts b/waspc/data/Generator/templates/sdk/wasp/server/actions/index.ts new file mode 100644 index 0000000000..5f9800cf8d --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/server/actions/index.ts @@ -0,0 +1,39 @@ +import prisma from 'wasp/server/dbClient.js' +import { + updateTask as updateTaskUser, + createTask as createTaskUser, + deleteTasks as deleteTasksUser, +} from 'wasp/ext-src/actions.js' + +export type UpdateTask = typeof updateTask + +export const updateTask = async (args, context) => { + return (updateTaskUser as any)(args, { + ...context, + entities: { + Task: prisma.task, + }, + }) +} + +export type CreateTask = typeof createTask + +export const createTask = async (args, context) => { + return (createTaskUser as any)(args, { + ...context, + entities: { + Task: prisma.task, + }, + }) +} + +export type DeleteTasks = typeof deleteTasks + +export const deleteTasks = async (args, context) => { + return (deleteTasksUser as any)(args, { + ...context, + entities: { + Task: prisma.task, + }, + }) +} diff --git a/waspc/data/Generator/templates/sdk/wasp/server/actions/types.ts b/waspc/data/Generator/templates/sdk/wasp/server/actions/types.ts new file mode 100644 index 0000000000..8e03d4963e --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/server/actions/types.ts @@ -0,0 +1,34 @@ +import { + type _Task, + type AuthenticatedAction, + type Payload, +} from '../_types' + +export type CreateTask = + AuthenticatedAction< + [ + _Task, + ], + Input, + Output + > + +export type UpdateTask = + AuthenticatedAction< + [ + _Task, + ], + Input, + Output + > + +export type DeleteTasks = + AuthenticatedAction< + [ + _Task, + ], + Input, + Output + > + + diff --git a/waspc/data/Generator/templates/sdk/wasp/server/dbClient.ts b/waspc/data/Generator/templates/sdk/wasp/server/dbClient.ts new file mode 100644 index 0000000000..66e7801be3 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/server/dbClient.ts @@ -0,0 +1,12 @@ +import Prisma from '@prisma/client' + + +const createDbClient = () => { + const prismaClient = new Prisma.PrismaClient() + + return prismaClient +} + +const dbClient = createDbClient() + +export default dbClient diff --git a/waspc/data/Generator/templates/sdk/wasp/server/queries/index.ts b/waspc/data/Generator/templates/sdk/wasp/server/queries/index.ts new file mode 100644 index 0000000000..3c49adc9dc --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/server/queries/index.ts @@ -0,0 +1,13 @@ +import prisma from 'wasp/server/dbClient.js' +import { getTasks as getTasksUser } from 'wasp/ext-src/queries.js' + +export type GetTasks = typeof getTasksUser + +export const getTasks = async (args, context) => { + return (getTasksUser as any)(args, { + ...context, + entities: { + Task: prisma.task, + }, + }) +} diff --git a/waspc/data/Generator/templates/sdk/wasp/server/queries/types.ts b/waspc/data/Generator/templates/sdk/wasp/server/queries/types.ts new file mode 100644 index 0000000000..0617ad4559 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/server/queries/types.ts @@ -0,0 +1,6 @@ +import { type _Task, type AuthenticatedQuery, type Payload } from "../_types"; + +export type GetTasks< + Input extends Payload = never, + Output extends Payload = Payload +> = AuthenticatedQuery<[_Task], Input, Output>; diff --git a/waspc/data/Generator/templates/sdk/wasp/server/utils.ts b/waspc/data/Generator/templates/sdk/wasp/server/utils.ts new file mode 100644 index 0000000000..b0744f3129 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/server/utils.ts @@ -0,0 +1,67 @@ +import crypto from 'crypto' +import { Request, Response, NextFunction } from 'express' + +import { readdir } from 'fs' +import { dirname } from 'path' +import { fileURLToPath } from 'url' + +import { type SanitizedUser } from './_types/index.js' + +type RequestWithExtraFields = Request & { + user?: SanitizedUser; + sessionId?: string; +} + +/** + * Decorator for async express middleware that handles promise rejections. + * @param {Func} middleware - Express middleware function. + * @returns Express middleware that is exactly the same as the given middleware but, + * if given middleware returns promise, reject of that promise will be correctly handled, + * meaning that error will be forwarded to next(). + */ +export const handleRejection = ( + middleware: ( + req: RequestWithExtraFields, + res: Response, + next: NextFunction + ) => any +) => +async (req: RequestWithExtraFields, res: Response, next: NextFunction) => { + try { + await middleware(req, res, next) + } catch (error) { + next(error) + } +} + +export const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + +export function getDirPathFromFileUrl(fileUrl: string): string { + return fileURLToPath(dirname(fileUrl)) +} + +export async function importJsFilesFromDir( + pathToDir: string, + whitelistedFileNames: string[] | null = null +): Promise { + return new Promise((resolve, reject) => { + readdir(pathToDir, async (err, files) => { + if (err) { + return reject(err) + } + const importPromises = files + .filter((file) => file.endsWith('.js') && isWhitelistedFileName(file)) + .map((file) => import(`${pathToDir}/${file}`)) + resolve(Promise.all(importPromises)) + }) + }) + + function isWhitelistedFileName(fileName: string) { + // No whitelist means all files are whitelisted + if (!Array.isArray(whitelistedFileNames)) { + return true + } + + return whitelistedFileNames.some((whitelistedFileName) => fileName === whitelistedFileName) + } +} diff --git a/waspc/data/Generator/templates/sdk/wasp/types/index.ts b/waspc/data/Generator/templates/sdk/wasp/types/index.ts new file mode 100644 index 0000000000..982b766e37 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/types/index.ts @@ -0,0 +1,9 @@ +// 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 } diff --git a/waspc/data/Generator/templates/sdk/wasp/universal/types.ts b/waspc/data/Generator/templates/sdk/wasp/universal/types.ts new file mode 100644 index 0000000000..8cadbd740d --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/universal/types.ts @@ -0,0 +1,31 @@ +// This is a helper type used exclusively for DX purposes. It's a No-op for the +// compiler, but expands the type's representatoin in IDEs (i.e., inlines all +// type constructors) to make it more readable for the user. +// +// It expands this SO answer to functions: https://stackoverflow.com/a/57683652 +export type Expand = T extends (...args: infer A) => infer R + ? (...args: A) => R + : T extends infer O + ? { [K in keyof O]: O[K] } + : never + +// TypeScript's native Awaited type exhibits strange behavior in VS Code (see +// https://github.com/wasp-lang/wasp/pull/1090#discussion_r1159687537 for +// details). Until it's fixed, we're using our own type for this. +// +// TODO: investigate further. This most likely has something to do with an +// unsatisfied 'extends' constraints. A mismatch is probably happening with +// function parameter types and/or return types (check '_ReturnType' below for +// more). +export type _Awaited = T extends Promise + ? _Awaited + : T + +// TypeScript's native ReturnType does not work for functions of type '(...args: +// never[]) => unknown' (and that's what operations currently use). +// +// TODO: investigate how to properly specify the 'extends' constraint for function +// type (i.e., any vs never and unknown) and stick with that. Take DX into +// consideration. +export type _ReturnType unknown> = + T extends (...args: never[]) => infer R ? R : never diff --git a/waspc/data/Generator/templates/sdk/wasp/universal/url.ts b/waspc/data/Generator/templates/sdk/wasp/universal/url.ts new file mode 100644 index 0000000000..d21c06c65c --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/universal/url.ts @@ -0,0 +1,3 @@ +export function stripTrailingSlash(url?: string): string | undefined { + return url?.replace(/\/$/, ""); +} diff --git a/waspc/data/Generator/templates/server/nodemon.json b/waspc/data/Generator/templates/server/nodemon.json index 9ac8c1df77..01fe71701a 100644 --- a/waspc/data/Generator/templates/server/nodemon.json +++ b/waspc/data/Generator/templates/server/nodemon.json @@ -4,7 +4,9 @@ }, "watch": [ "src/", + "../../../src/", ".env" ], + "comment-filip": "We now have to watch ../../../src/ because we're importing client files directly", "ext": "ts,mts,js,mjs,json" } diff --git a/waspc/data/Generator/templates/server/package.json b/waspc/data/Generator/templates/server/package.json index 0b3f962d56..1a80a9ee47 100644 --- a/waspc/data/Generator/templates/server/package.json +++ b/waspc/data/Generator/templates/server/package.json @@ -4,9 +4,10 @@ "version": "0.0.0", "private": true, "type": "module", + "comment-filip": "The server.js location changed because we have now included client source files above .wasp/out/server/src.", "scripts": { "build": "npx tsc", - "start": "npm run validate-env && NODE_PATH=dist node -r dotenv/config dist/server.js", + "start": "npm run validate-env && NODE_PATH=dist node -r dotenv/config dist/.wasp/out/server/src/server.js", "build-and-start": "npm run build && npm run start", "watch": "nodemon --exec 'npm run build-and-start || exit 1'", "validate-env": "node -r dotenv/config ./scripts/validate-env.mjs", diff --git a/waspc/data/Generator/templates/server/src/app.js b/waspc/data/Generator/templates/server/src/app.js index a15cb96cea..9db0e1b75d 100644 --- a/waspc/data/Generator/templates/server/src/app.js +++ b/waspc/data/Generator/templates/server/src/app.js @@ -1,6 +1,6 @@ import express from 'express' -import HttpError from './core/HttpError.js' +import HttpError from 'wasp/core/HttpError' import indexRouter from './routes/index.js' // TODO: Consider extracting most of this logic into createApp(routes, path) function so that diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts index c0936bac59..194da085d5 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts @@ -13,7 +13,7 @@ import { import { ensureValidEmail } from "../../validation.js"; import type { EmailFromField } from '../../../email/core/types.js'; import { GetPasswordResetEmailContentFn } from './types.js'; -import HttpError from '../../../core/HttpError.js'; +import HttpError from 'wasp/core/HttpError' export function getRequestPasswordResetRoute({ fromField, diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/resetPassword.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/resetPassword.ts index 3f01d47c32..6d8f0d6753 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/resetPassword.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/resetPassword.ts @@ -8,7 +8,7 @@ import { } from "../../utils.js"; import { ensureTokenIsPresent, ensurePasswordIsPresent, ensureValidPassword } from "../../validation.js"; import { tokenVerificationErrors } from "./types.js"; -import HttpError from '../../../core/HttpError.js'; +import HttpError from 'wasp/core/HttpError'; export async function resetPassword( req: Request<{ token: string; password: string; }>, diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts index 05d5b0368a..984f38362d 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts @@ -18,7 +18,7 @@ import { import { ensureValidEmail, ensureValidPassword, ensurePasswordIsPresent } from "../../validation.js"; import { GetVerificationEmailContentFn } from './types.js'; import { validateAndGetUserFields } from '../../utils.js' -import HttpError from '../../../core/HttpError.js'; +import HttpError from 'wasp/core/HttpError'; import { type UserSignupFields } from '../types.js'; export function getSignupRoute({ diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/verifyEmail.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/verifyEmail.ts index 7dc52d2576..de73a05ddf 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/verifyEmail.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/verifyEmail.ts @@ -7,7 +7,7 @@ import { deserializeAndSanitizeProviderData, } from '../../utils.js'; import { tokenVerificationErrors } from './types.js'; -import HttpError from '../../../core/HttpError.js'; +import HttpError from 'wasp/core/HttpError'; export async function verifyEmail( diff --git a/waspc/data/Generator/templates/server/src/auth/utils.ts b/waspc/data/Generator/templates/server/src/auth/utils.ts index 48a1f72855..bd63be6c79 100644 --- a/waspc/data/Generator/templates/server/src/auth/utils.ts +++ b/waspc/data/Generator/templates/server/src/auth/utils.ts @@ -1,8 +1,8 @@ {{={= =}=}} import { hashPassword } from './password.js' import { verify } from './jwt.js' -import AuthError from '../core/AuthError.js' -import HttpError from '../core/HttpError.js' +import AuthError from 'wasp/core/AuthError' +import HttpError from 'wasp/core/HttpError' import prisma from '../dbClient.js' import { sleep } from '../utils.js' import { diff --git a/waspc/data/Generator/templates/server/src/auth/validation.ts b/waspc/data/Generator/templates/server/src/auth/validation.ts index f384a28c87..96f755cac3 100644 --- a/waspc/data/Generator/templates/server/src/auth/validation.ts +++ b/waspc/data/Generator/templates/server/src/auth/validation.ts @@ -1,4 +1,4 @@ -import HttpError from '../core/HttpError.js'; +import HttpError from 'wasp/core/HttpError' export const PASSWORD_FIELD = 'password'; const USERNAME_FIELD = 'username'; diff --git a/waspc/data/Generator/templates/server/tsconfig.json b/waspc/data/Generator/templates/server/tsconfig.json index 6713168c4a..14433f8119 100644 --- a/waspc/data/Generator/templates/server/tsconfig.json +++ b/waspc/data/Generator/templates/server/tsconfig.json @@ -3,6 +3,15 @@ "extends": "@tsconfig/node{= majorNodeVersion =}/tsconfig.json", "compilerOptions": { // Overriding this until we implement more complete TypeScript support. + // Filip: begin client file hacks + // We need this to make server work with copied client files (we copy everything) + "jsx": "preserve", + "lib": [ + "esnext", + "dom", + "DOM.Iterable" + ], + // Filip: end client file hacks "strict": false, // Overriding this because we want to use top-level await "module": "esnext", @@ -11,12 +20,13 @@ "sourceMap": true, // The remaining settings should match the extended nodeXY/tsconfig.json, but I kept // them here to be explicit. - // Enable default imports in TypeScript. "esModuleInterop": true, "moduleResolution": "node", "outDir": "dist", "allowJs": true }, - "include": ["src"] -} + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/waspc/examples/crud-testing/src/server/auth_simple.js b/waspc/examples/crud-testing/src/server/auth_simple.js index ef190dd047..ebac9fa278 100644 --- a/waspc/examples/crud-testing/src/server/auth_simple.js +++ b/waspc/examples/crud-testing/src/server/auth_simple.js @@ -1,4 +1,4 @@ -import { defineUserSignupFields } from '@wasp/auth/index.js' +import { defineUserSignupFields } from 'wasp/auth/index.js' export const userSignupFields = defineUserSignupFields({ address: (data) => data.address, diff --git a/waspc/examples/todo-typescript/.gitignore b/waspc/examples/todo-typescript/.gitignore new file mode 100644 index 0000000000..ab7cafccec --- /dev/null +++ b/waspc/examples/todo-typescript/.gitignore @@ -0,0 +1,4 @@ +/.wasp/ +/.env.server +/.env.client +/node_modules/ diff --git a/waspc/examples/todo-typescript/.wasproot b/waspc/examples/todo-typescript/.wasproot new file mode 100644 index 0000000000..ca2cfdb482 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasproot @@ -0,0 +1 @@ +File marking the root of Wasp project. diff --git a/waspc/examples/todo-typescript/main.wasp b/waspc/examples/todo-typescript/main.wasp new file mode 100644 index 0000000000..a2dd70db1c --- /dev/null +++ b/waspc/examples/todo-typescript/main.wasp @@ -0,0 +1,75 @@ +app TodoTypescript { + wasp: { + version: "^0.12.0" + }, + title: "ToDo TypeScript", + + auth: { + userEntity: User, + methods: { + usernameAndPassword: {}, // this is a very naive implementation, use 'email' in production instead + //google: {}, //https://wasp-lang.dev/docs/integrations/google + //gitHub: {}, //https://wasp-lang.dev/docs/integrations/github + //email: {} //https://wasp-lang.dev/docs/guides/email-auth + }, + onAuthFailedRedirectTo: "/login", + } +} + +// Use Prisma Schema Language (PSL) to define our entities: https://www.prisma.io/docs/concepts/components/prisma-schema +// Run `wasp db migrate-dev` in the CLI to create the database tables +// Then run `wasp db studio` to open Prisma Studio and view your db models +entity User {=psl + id Int @id @default(autoincrement()) + tasks Task[] +psl=} + +entity Task {=psl + id Int @id @default(autoincrement()) + description String + isDone Boolean @default(false) + user User @relation(fields: [userId], references: [id]) + userId Int +psl=} + +route RootRoute { path: "/", to: MainPage } +page MainPage { + authRequired: true, + // todo(filip): LSP features are broken beucase I haven't yet updated LSP to the new structure. + component: import { MainPage } from "@src/MainPage.tsx" +} + +route LoginRoute { path: "/login", to: LoginPage } +page LoginPage { + component: import { LoginPage } from "@src/user/LoginPage.tsx" +} + +route SignupRoute { path: "/signup", to: SignupPage } +page SignupPage { + component: import { SignupPage } from "@src/user/SignupPage.tsx" +} + +query getTasks { + // We specify the JS implementation of our query (which is an async JS function) + // Even if you use TS and have a queries.ts file, you will still need to import it using the .js extension. + // see here for more info: https://wasp-lang.dev/docs/tutorials/todo-app/03-listing-tasks#wasp-declaration + fn: import { getTasks } from "@src/task/queries.js", + // We tell Wasp that this query is doing something with the `Task` entity. With that, Wasp will + // automatically refresh the results of this query when tasks change. + entities: [Task] +} + +action createTask { + fn: import { createTask } from "@src/task/actions.js", + entities: [Task] +} + +action updateTask { + fn: import { updateTask } from "@src/task/actions.js", + entities: [Task] +} + +action deleteTasks { + fn: import { deleteTasks } from "@src/task/actions.js", + entities: [Task], +} diff --git a/waspc/examples/todo-typescript/migrate b/waspc/examples/todo-typescript/migrate new file mode 100755 index 0000000000..3c41785806 --- /dev/null +++ b/waspc/examples/todo-typescript/migrate @@ -0,0 +1,6 @@ +rsync -a .wasp/out/web-app/node_modules/ node_modules/ +rsync -a .wasp/out/server/node_modules/ node_modules/ +# rsync -a node_modules_wasp/ node_modules +cabal run wasp-cli db migrate-dev +find .wasp/out/server/node_modules -mindepth 1 -type d | grep -Eiv 'prisma|\.bin' | xargs rm -r 2> /dev/null +rm -r .wasp/out/web-app/node_modules diff --git a/examples/todo-typescript/migrations/20231214130914_new_auth/migration.sql b/waspc/examples/todo-typescript/migrations/20240119151915_init/migration.sql similarity index 72% rename from examples/todo-typescript/migrations/20231214130914_new_auth/migration.sql rename to waspc/examples/todo-typescript/migrations/20240119151915_init/migration.sql index 0ea8e16da6..919941fb15 100644 --- a/examples/todo-typescript/migrations/20231214130914_new_auth/migration.sql +++ b/waspc/examples/todo-typescript/migrations/20240119151915_init/migration.sql @@ -30,5 +30,19 @@ CREATE TABLE "AuthIdentity" ( CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "expiresAt" DATETIME NOT NULL, + "userId" TEXT NOT NULL, + CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + -- CreateIndex CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id"); + +-- CreateIndex +CREATE INDEX "Session_userId_idx" ON "Session"("userId"); diff --git a/examples/todo-typescript/migrations/migration_lock.toml b/waspc/examples/todo-typescript/migrations/migration_lock.toml similarity index 100% rename from examples/todo-typescript/migrations/migration_lock.toml rename to waspc/examples/todo-typescript/migrations/migration_lock.toml diff --git a/waspc/examples/todo-typescript/package.json b/waspc/examples/todo-typescript/package.json new file mode 100644 index 0000000000..3884f98818 --- /dev/null +++ b/waspc/examples/todo-typescript/package.json @@ -0,0 +1,12 @@ +{ + "name": "prototype", + "dependencies": { + "@prisma/client": "^4.16.2", + "react": "18.2.0", + "wasp": "file:.wasp/out/sdk/wasp" + }, + "devDependencies": { + "@types/react": "^18.0.37", + "prisma": "^4.16.2" + } +} diff --git a/waspc/examples/todo-typescript/src/.waspignore b/waspc/examples/todo-typescript/src/.waspignore new file mode 100644 index 0000000000..1c432f30d9 --- /dev/null +++ b/waspc/examples/todo-typescript/src/.waspignore @@ -0,0 +1,3 @@ +# Ignore editor tmp files +**/*~ +**/#*# diff --git a/waspc/examples/todo-typescript/src/Main.css b/waspc/examples/todo-typescript/src/Main.css new file mode 100644 index 0000000000..15a61f2399 --- /dev/null +++ b/waspc/examples/todo-typescript/src/Main.css @@ -0,0 +1,73 @@ +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + box-sizing: border-box; +} + +main { + padding: 1rem 0; + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +h1 { + padding: 0; + margin: 1rem 0; +} + +main p { + font-size: 1rem; +} + +img { + max-height: 100px; +} + +.logout { + margin-top: 1rem; +} + +code { + border-radius: 5px; + padding: 0.2rem; + background: #efefef; + font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, + Bitstream Vera Sans Mono, Courier New, monospace; +} + +.auth-form h2 { + margin-top: 0.5rem; + font-size: 1.2rem; +} + +.buttons { + display: flex; + flex-direction: row; + width: 300px; + justify-content: space-between; +} + +.tasklist { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + width: 300px; + margin-top: 1rem; + padding: 0 +} + +li { + width: 100%; +} + +.todo-item { + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} diff --git a/waspc/examples/todo-typescript/src/MainPage.tsx b/waspc/examples/todo-typescript/src/MainPage.tsx new file mode 100644 index 0000000000..3d43e28deb --- /dev/null +++ b/waspc/examples/todo-typescript/src/MainPage.tsx @@ -0,0 +1,107 @@ +import './Main.css' +import React, { useEffect, FormEventHandler, FormEvent } from 'react' +import logout from 'wasp/auth/logout' +import { useQuery, useAction } from 'wasp/rpc' // Wasp uses a thin wrapper around react-query +import { getTasks } from 'wasp/rpc/queries' +import { createTask, updateTask, deleteTasks } from 'wasp/rpc/actions' +import waspLogo from './waspLogo.png' +import type { Task } from 'wasp/entities' +import type { User } from 'wasp/auth/types' +import { getUsername } from 'wasp/auth/user' + +export const MainPage = ({ user }: { user: User }) => { + const { data: tasks, isLoading, error } = useQuery(getTasks) + + if (isLoading) return 'Loading...' + if (error) return 'Error: ' + error + + const completed = tasks?.filter((task) => task.isDone).map((task) => task.id) + + return ( +
+ wasp logo + {user && ( +

+ {getUsername(user)} + {`'s tasks :)`} +

+ )} + + {tasks && } +
+ + +
+
+ ) +} + +function Todo({ id, isDone, description }: Task) { + const handleIsDoneChange: FormEventHandler = async ( + event + ) => { + try { + await updateTask({ + id, + isDone: event.currentTarget.checked, + }) + } catch (err: any) { + window.alert('Error while updating task ' + err?.message) + } + } + + return ( +
  • + + + {description} + + +
  • + ) +} + +function TasksList({ tasks }: { tasks: Task[] }) { + if (tasks.length === 0) return

    No tasks yet.

    + return ( +
      + {tasks.map((task, idx) => ( + + ))} +
    + ) +} + +function NewTaskForm() { + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + + try { + const description = event.currentTarget.description.value + console.log(description) + event.currentTarget.reset() + await createTask({ description }) + } catch (err: any) { + window.alert('Error: ' + err?.message) + } + } + + return ( +
    + + +
    + ) +} diff --git a/waspc/examples/todo-typescript/src/task/actions.ts b/waspc/examples/todo-typescript/src/task/actions.ts new file mode 100644 index 0000000000..c03bfac62b --- /dev/null +++ b/waspc/examples/todo-typescript/src/task/actions.ts @@ -0,0 +1,56 @@ +import HttpError from 'wasp/core/HttpError' +import type { + CreateTask, + UpdateTask, + DeleteTasks, +} from 'wasp/server/actions/types' +import type { Task } from 'wasp/entities' + +type CreateArgs = Pick + +export const createTask: CreateTask = async ( + { description }, + context +) => { + if (!context.user) { + throw new HttpError(401) + } + + return context.entities.Task.create({ + data: { + description, + user: { connect: { id: context.user.id } }, + }, + }) +} + +type UpdateArgs = Pick + +export const updateTask: UpdateTask = async ( + { id, isDone }, + context +) => { + if (!context.user) { + throw new HttpError(401) + } + + return context.entities.Task.update({ + where: { + id, + }, + data: { isDone }, + }) +} + +export const deleteTasks: DeleteTasks = async ( + idsToDelete, + context +) => { + return context.entities.Task.deleteMany({ + where: { + id: { + in: idsToDelete, + }, + }, + }) +} diff --git a/waspc/examples/todo-typescript/src/task/queries.ts b/waspc/examples/todo-typescript/src/task/queries.ts new file mode 100644 index 0000000000..ac49e0a7a7 --- /dev/null +++ b/waspc/examples/todo-typescript/src/task/queries.ts @@ -0,0 +1,15 @@ +import HttpError from 'wasp/core/HttpError' +import type { GetTasks } from 'wasp/server/queries/types' +import type { Task } from 'wasp/entities' + +//Using TypeScript's new 'satisfies' keyword, it will infer the types of the arguments and return value +export const getTasks = ((_args, context) => { + if (!context.user) { + throw new HttpError(401) + } + + return context.entities.Task.findMany({ + where: { user: { id: context.user.id } }, + orderBy: { id: 'asc' }, + }) +}) satisfies GetTasks diff --git a/waspc/examples/todo-typescript/src/user/LoginPage.tsx b/waspc/examples/todo-typescript/src/user/LoginPage.tsx new file mode 100644 index 0000000000..fa198154ab --- /dev/null +++ b/waspc/examples/todo-typescript/src/user/LoginPage.tsx @@ -0,0 +1,17 @@ +import { Link } from 'react-router-dom' +import { LoginForm } from 'wasp/auth/forms/Login' + +export function LoginPage() { + return ( +
    + {/** Wasp has built-in auth forms & flows, which you can customize or opt-out of, if you wish :) + * https://wasp-lang.dev/docs/guides/auth-ui + */} + +
    + + I don't have an account yet (go to signup). + +
    + ) +} diff --git a/waspc/examples/todo-typescript/src/user/SignupPage.tsx b/waspc/examples/todo-typescript/src/user/SignupPage.tsx new file mode 100644 index 0000000000..e3599729d6 --- /dev/null +++ b/waspc/examples/todo-typescript/src/user/SignupPage.tsx @@ -0,0 +1,17 @@ +import { Link } from 'react-router-dom' +import { SignupForm } from 'wasp/auth/forms/Signup' + +export function SignupPage() { + return ( +
    + {/** Wasp has built-in auth forms & flows, which you can customize or opt-out of, if you wish :) + * https://wasp-lang.dev/docs/guides/auth-ui + */} + +
    + + I already have an account (go to login). + +
    + ) +} diff --git a/waspc/examples/todo-typescript/src/vite-env.d.ts b/waspc/examples/todo-typescript/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/waspc/examples/todo-typescript/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/waspc/examples/todo-typescript/src/waspLogo.png b/waspc/examples/todo-typescript/src/waspLogo.png new file mode 100644 index 0000000000..d39a9443a8 Binary files /dev/null and b/waspc/examples/todo-typescript/src/waspLogo.png differ diff --git a/waspc/examples/todo-typescript/tsconfig.json b/waspc/examples/todo-typescript/tsconfig.json new file mode 100644 index 0000000000..93c79bf3d8 --- /dev/null +++ b/waspc/examples/todo-typescript/tsconfig.json @@ -0,0 +1,28 @@ +// =============================== IMPORTANT ================================= +// +// This file is only used for Wasp IDE support. You can change it to configure +// your IDE checks, but none of these options will affect the TypeScript +// compiler. Proper TS compiler configuration in Wasp is coming soon :) +{ + "compilerOptions": { + // JSX support + "jsx": "preserve", + "strict": true, + // Allow default imports. + "esModuleInterop": true, + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + // Since this TS config is used only for IDE support and not for + // compilation, the following directory doesn't exist. We need to specify + // it to prevent this error: + // https://stackoverflow.com/questions/42609768/typescript-error-cannot-write-file-because-it-would-overwrite-input-file + "outDir": "phantom" + }, + "exclude": [ + "phantom" + ], +} \ No newline at end of file diff --git a/waspc/src/Wasp/Analyzer/Evaluator/Evaluation/TypedExpr/Combinators.hs b/waspc/src/Wasp/Analyzer/Evaluator/Evaluation/TypedExpr/Combinators.hs index e0da203357..f1baae97cc 100644 --- a/waspc/src/Wasp/Analyzer/Evaluator/Evaluation/TypedExpr/Combinators.hs +++ b/waspc/src/Wasp/Analyzer/Evaluator/Evaluation/TypedExpr/Combinators.hs @@ -169,13 +169,21 @@ extImport = evaluation' . withCtx $ \ctx -> \case Nothing -> mkParseError ctx - $ "Path in external import must start with \"" ++ serverPrefix ++ "\"" ++ " or \"" ++ clientPrefix ++ "\"!" + $ "Path in external import must start with \"" ++ extSrcPrefix ++ "\"!" expr -> Left $ ER.mkEvaluationError ctx $ ER.ExpectedType T.ExtImportType (TypedAST.exprType expr) where mkParseError ctx msg = Left $ ER.mkEvaluationError ctx $ ER.ParseError $ ER.EvaluationParseError msg - stripImportPrefix importPath = stripPrefix serverPrefix importPath <|> stripPrefix clientPrefix importPath - serverPrefix = "@server/" - clientPrefix = "@client/" + stripImportPrefix importPath = stripPrefix extSrcPrefix importPath + -- Filip: We no longer want separation between client and server code + -- todo (filip): Do we still want to know whic is which. We might (because of the reloading). + -- For now, as we'd like (expect): + -- - Nodemon watches all files in the user's source folder (client files + -- included), but tsc only compiles the server files (I think because it + -- knows that the others aren't used). I am not yet sure how it knows this. + -- - Vite also only triggers on client files. I am not sure how it knows + -- about the difference either. + -- todo (filip): investigate + extSrcPrefix = "@src/" -- | An evaluation that expects a "JSON". json :: TypedExprEvaluation AppSpec.JSON.JSON diff --git a/waspc/src/Wasp/Generator.hs b/waspc/src/Wasp/Generator.hs index ab60e1c1d0..8ba308cc03 100644 --- a/waspc/src/Wasp/Generator.hs +++ b/waspc/src/Wasp/Generator.hs @@ -21,6 +21,7 @@ import Wasp.Generator.DbGenerator (genDb) import Wasp.Generator.DockerGenerator (genDockerFiles) import Wasp.Generator.FileDraft (FileDraft) import Wasp.Generator.Monad (Generator, GeneratorError, GeneratorWarning, runGenerator) +import Wasp.Generator.SdkGenerator (genSdk) import Wasp.Generator.ServerGenerator (genServer) import Wasp.Generator.Setup (runSetup) import qualified Wasp.Generator.Start @@ -54,6 +55,7 @@ genApp :: AppSpec -> Generator [FileDraft] genApp spec = genWebApp spec <++> genServer spec + <++> genSdk spec <++> genDb spec <++> genDockerFiles spec <++> genConfigFiles spec diff --git a/waspc/src/Wasp/Generator/ExternalCodeGenerator/Js.hs b/waspc/src/Wasp/Generator/ExternalCodeGenerator/Js.hs index 3378ca1b01..1cd956a56c 100644 --- a/waspc/src/Wasp/Generator/ExternalCodeGenerator/Js.hs +++ b/waspc/src/Wasp/Generator/ExternalCodeGenerator/Js.hs @@ -18,15 +18,10 @@ import qualified Wasp.Generator.FileDraft as FD import Wasp.Generator.Monad (Generator) genSourceFile :: C.ExternalCodeGeneratorStrategy -> EC.File -> Generator FD.FileDraft -genSourceFile strategy file = return $ FD.createTextFileDraft dstPath text' +genSourceFile strategy file = return $ FD.createTextFileDraft dstPath text where filePathInSrcExtCodeDir = EC.filePathInExtCodeDir file - - filePathInGenExtCodeDir :: Path' (Rel C.GeneratedExternalCodeDir) File' - filePathInGenExtCodeDir = C.castRelPathFromSrcToGenExtCodeDir filePathInSrcExtCodeDir - text = EC.fileText file - text' = C._resolveJsFileWaspImports strategy filePathInGenExtCodeDir text dstPath = C._resolveDstFilePath strategy filePathInSrcExtCodeDir -- | Replaces imports that start with "@wasp/" with imports that start from the src dir of the app. diff --git a/waspc/src/Wasp/Generator/JsImport.hs b/waspc/src/Wasp/Generator/JsImport.hs index 25db2c7df3..c5da3f7a74 100644 --- a/waspc/src/Wasp/Generator/JsImport.hs +++ b/waspc/src/Wasp/Generator/JsImport.hs @@ -34,6 +34,22 @@ extImportToJsImport pathFromSrcDirToExtCodeDir pathFromImportLocationToSrcDir ex extImportNameToJsImportName (EI.ExtImportModule name) = JsImportModule name extImportNameToJsImportName (EI.ExtImportField name) = JsImportField name +-- filip: attempt to simplify how we generate imports. I wanted to generate a +-- module import (e.g., '@ext-src/something') and couldn't do it +-- jsImportToImportJsonRaw :: Maybe (FilePath, JsImportName, Maybe JsImportAlias) -> Aeson.Value +-- jsImportToImportJsonRaw importData = maybe notDefinedValue mkTmplData importData +-- where +-- notDefinedValue = object ["isDefined" .= False] + +-- mkTmplData :: (FilePath, JsImportName, Maybe JsImportAlias) -> Aeson.Value +-- mkTmplData (importPath, importName, maybeImportAlias) = +-- let (jsImportStmt, jsImportIdentifier) = getJsImportStmtAndIdentifierRaw importPath importName maybeImportAlias +-- in object +-- [ "isDefined" .= True, +-- "importStatement" .= jsImportStmt, +-- "importIdentifier" .= jsImportIdentifier +-- ] + jsImportToImportJson :: Maybe JsImport -> Aeson.Value jsImportToImportJson maybeJsImport = maybe notDefinedValue mkTmplData maybeJsImport where diff --git a/waspc/src/Wasp/Generator/SdkGenerator.hs b/waspc/src/Wasp/Generator/SdkGenerator.hs new file mode 100644 index 0000000000..f092acc0a1 --- /dev/null +++ b/waspc/src/Wasp/Generator/SdkGenerator.hs @@ -0,0 +1,90 @@ +module Wasp.Generator.SdkGenerator where + +import Data.Aeson (object) +import qualified Data.Aeson as Aeson +import Data.Aeson.Types ((.=)) +import GHC.IO (unsafePerformIO) +import StrongPath +import Wasp.AppSpec +import qualified Wasp.AppSpec.App.Dependency as AS.Dependency +import Wasp.AppSpec.Valid (isAuthEnabled) +import Wasp.Generator.Common (ProjectRootDir, prismaVersion) +import Wasp.Generator.FileDraft (FileDraft, createCopyDirFileDraft, createTemplateFileDraft) +import Wasp.Generator.FileDraft.CopyDirFileDraft (CopyDirFileDraftDstDirStrategy (RemoveExistingDstDir)) +import Wasp.Generator.Monad (Generator) +import qualified Wasp.Generator.NpmDependencies as N +import Wasp.Generator.Templates (TemplatesDir, getTemplatesDirAbsPath) +import qualified Wasp.SemanticVersion as SV + +genSdk :: AppSpec -> Generator [FileDraft] +genSdk spec = sequence [genSdkModules, genPackageJson spec] + +data SdkRootDir + +data SdkTemplatesDir + +genSdkModules :: Generator FileDraft +genSdkModules = + return $ + createCopyDirFileDraft + RemoveExistingDstDir + sdkRootDirInProjectRootDir + (unsafePerformIO getTemplatesDirAbsPath sdkTemplatesDirInTemplatesDir [reldir|wasp|]) + +genPackageJson :: AppSpec -> Generator FileDraft +genPackageJson spec = + return $ + mkTmplFdWithDstAndData + [relfile|package.json|] + [relfile|package.json|] + ( Just $ + object + [ "depsChunk" .= N.getDependenciesPackageJsonEntry npmDepsForSdk, + "devDepsChunk" .= N.getDevDependenciesPackageJsonEntry npmDepsForSdk + ] + ) + where + npmDepsForSdk = + N.NpmDepsForPackage + { N.dependencies = + AS.Dependency.fromList + [ ("@prisma/client", show prismaVersion), + ("prisma", show prismaVersion), + ("@tanstack/react-query", "^4.29.0"), + ("axios", "^1.4.0"), + ("express", "~4.18.1"), + ("jsonwebtoken", "^8.5.1"), + ("mitt", "3.0.0"), + ("react", "^18.2.0"), + ("react-router-dom", "^5.3.3"), + ("react-hook-form", "^7.45.4"), + ("secure-password", "^4.0.0"), + ("superjson", "^1.12.2"), + ("@types/express-serve-static-core", "^4.17.13") + ] + ++ depsRequiredForAuth spec, + N.devDependencies = AS.Dependency.fromList [] + } + +depsRequiredForAuth :: AppSpec -> [AS.Dependency.Dependency] +depsRequiredForAuth spec = + [AS.Dependency.make ("@stitches/react", show versionRange) | isAuthEnabled spec] + where + versionRange = SV.Range [SV.backwardsCompatibleWith (SV.Version 1 2 8)] + +mkTmplFdWithDstAndData :: + Path' (Rel SdkTemplatesDir) File' -> + Path' (Rel SdkRootDir) File' -> + Maybe Aeson.Value -> + FileDraft +mkTmplFdWithDstAndData relSrcPath relDstPath tmplData = + createTemplateFileDraft + (sdkRootDirInProjectRootDir relDstPath) + (sdkTemplatesDirInTemplatesDir relSrcPath) + tmplData + +sdkRootDirInProjectRootDir :: Path' (Rel ProjectRootDir) (Dir SdkRootDir) +sdkRootDirInProjectRootDir = [reldir|sdk/wasp|] + +sdkTemplatesDirInTemplatesDir :: Path' (Rel TemplatesDir) (Dir SdkTemplatesDir) +sdkTemplatesDirInTemplatesDir = [reldir|sdk|] diff --git a/waspc/src/Wasp/Generator/ServerGenerator.hs b/waspc/src/Wasp/Generator/ServerGenerator.hs index 5fd7da0395..08caa5f2ea 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator.hs @@ -48,7 +48,6 @@ import Wasp.Generator.Common prismaVersion, ) import qualified Wasp.Generator.DbGenerator.Auth as DbAuth -import Wasp.Generator.ExternalCodeGenerator (genExternalCodeDir) import Wasp.Generator.FileDraft (FileDraft, createTextFileDraft) import Wasp.Generator.Monad (Generator) import qualified Wasp.Generator.NpmDependencies as N @@ -60,7 +59,6 @@ import Wasp.Generator.ServerGenerator.ConfigG (genConfigFile) import Wasp.Generator.ServerGenerator.CrudG (genCrud) import Wasp.Generator.ServerGenerator.Db.Seed (genDbSeed, getPackageJsonPrismaSeedField) import Wasp.Generator.ServerGenerator.EmailSenderG (depsRequiredByEmail, genEmailSender) -import Wasp.Generator.ServerGenerator.ExternalCodeGenerator (extServerCodeGeneratorStrategy, extSharedCodeGeneratorStrategy) import Wasp.Generator.ServerGenerator.JobGenerator (depsRequiredByJobs, genJobExecutors, genJobs) import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson, getAliasedJsImportStmtAndIdentifier) import Wasp.Generator.ServerGenerator.OperationsG (genOperations) @@ -82,8 +80,9 @@ genServer spec = genGitignore ] <++> genSrcDir spec - <++> genExternalCodeDir extServerCodeGeneratorStrategy (AS.externalServerFiles spec) - <++> genExternalCodeDir extSharedCodeGeneratorStrategy (AS.externalSharedFiles spec) + -- Filip: I don't generate external source folders as we're importing the user's code direclty (see ServerGenerator/JsImport.hs). + -- <++> genExternalCodeDir extServerCodeGeneratorStrategy (AS.externalServerFiles spec) + -- <++> genExternalCodeDir extSharedCodeGeneratorStrategy (AS.externalSharedFiles spec) <++> genDotEnv spec <++> genJobs spec <++> genJobExecutors spec @@ -222,8 +221,6 @@ genSrcDir :: AppSpec -> Generator [FileDraft] genSrcDir spec = sequence [ genFileCopy [relfile|app.js|], - genFileCopy [relfile|core/AuthError.js|], - genFileCopy [relfile|core/HttpError.js|], genDbClient spec, genConfigFile spec, genServerJs spec, diff --git a/waspc/src/Wasp/Generator/ServerGenerator/JsImport.hs b/waspc/src/Wasp/Generator/ServerGenerator/JsImport.hs index 448d9e05df..292264f898 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/JsImport.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/JsImport.hs @@ -1,13 +1,11 @@ module Wasp.Generator.ServerGenerator.JsImport where import qualified Data.Aeson as Aeson -import Data.Maybe (fromJust) import StrongPath (Dir, Path, Posix, Rel) -import qualified StrongPath as SP +import StrongPath.TH (reldirP) import qualified Wasp.AppSpec.ExtImport as EI import qualified Wasp.Generator.JsImport as GJI import Wasp.Generator.ServerGenerator.Common (ServerSrcDir) -import Wasp.Generator.ServerGenerator.ExternalCodeGenerator (extServerCodeDirInServerSrcDir) import Wasp.JsImport ( JsImport, JsImportAlias, @@ -44,4 +42,7 @@ extImportToJsImport :: JsImport extImportToJsImport = GJI.extImportToJsImport serverExtDir where - serverExtDir = fromJust (SP.relDirToPosix extServerCodeDirInServerSrcDir) + -- filip: Instead of generating the ext-src folder with the user's code and referencing that, we reference user code directly. + -- This gives us proper error messages (with user's file names and line numbers). + -- It works great with Vite (Vite outputs absolute file paths), but less great on the server (TS outputs relative paths, resulting in ../../src/something) + serverExtDir = [reldirP|../../../../src|] diff --git a/waspc/src/Wasp/Generator/WebAppGenerator.hs b/waspc/src/Wasp/Generator/WebAppGenerator.hs index 5c6ee9a780..c1c22c2b28 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator.hs @@ -32,11 +32,10 @@ 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.ExternalCode (SourceExternalCodeDir) -import Wasp.AppSpec.Valid (getApp, isAuthEnabled) +import Wasp.AppSpec.Valid (getApp) import Wasp.Env (envVarsToDotEnvContent) import Wasp.Generator.Common ( makeJsonWithEntityData, - prismaVersion, ) import qualified Wasp.Generator.ConfigFile as G.CF import qualified Wasp.Generator.DbGenerator.Auth as DbAuth @@ -52,7 +51,6 @@ import qualified Wasp.Generator.WebAppGenerator.Common as C import Wasp.Generator.WebAppGenerator.CrudG (genCrud) import Wasp.Generator.WebAppGenerator.ExternalCodeGenerator ( extClientCodeGeneratorStrategy, - extSharedCodeGeneratorStrategy, ) import qualified Wasp.Generator.WebAppGenerator.ExternalCodeGenerator as EC import Wasp.Generator.WebAppGenerator.JsImport (extImportToImportJson) @@ -65,7 +63,6 @@ import Wasp.JsImport makeJsImport, ) import qualified Wasp.Node.Version as NodeVersion -import qualified Wasp.SemanticVersion as SV import Wasp.Util ((<++>)) genWebApp :: AppSpec -> Generator [FileDraft] @@ -86,8 +83,9 @@ genWebApp spec = do genViteConfig spec ] <++> genSrcDir spec - <++> return extClientCodeFileDrafts - <++> genExternalCodeDir extSharedCodeGeneratorStrategy (AS.externalSharedFiles spec) + -- Filip: I don't generate external source folders as we're importing the user's code direclty (see ServerGenerator/JsImport.hs). + -- <++> return extClientCodeFileDrafts + -- <++> genExternalCodeDir extSharedCodeGeneratorStrategy (AS.externalSharedFiles spec) <++> genPublicDir spec extClientCodeFileDrafts <++> genDotEnv spec <++> genUniversalDir @@ -144,16 +142,11 @@ npmDepsForWasp spec = ("react-dom", "^18.2.0"), ("@tanstack/react-query", "^4.29.0"), ("react-router-dom", "^5.3.3"), - -- The web app only needs @prisma/client (we're using the server's - -- 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"), ("mitt", "3.0.0"), -- Used for Auth UI ("react-hook-form", "^7.45.4") ] - ++ depsRequiredForAuth spec ++ depsRequiredByTailwind spec ++ depsRequiredForWebSockets spec, N.waspDevDependencies = @@ -174,12 +167,6 @@ npmDepsForWasp spec = ++ depsRequiredForTesting } -depsRequiredForAuth :: AppSpec -> [AS.Dependency.Dependency] -depsRequiredForAuth spec = - [AS.Dependency.make ("@stitches/react", show versionRange) | isAuthEnabled spec] - where - versionRange = SV.Range [SV.backwardsCompatibleWith (SV.Version 1 2 8)] - depsRequiredByTailwind :: AppSpec -> [AS.Dependency.Dependency] depsRequiredByTailwind spec = if G.CF.isTailwindUsed spec @@ -328,10 +315,10 @@ genEnvValidationScript = genWebSockets :: AppSpec -> Generator [FileDraft] genWebSockets spec | AS.WS.areWebSocketsUsed spec = - sequence - [ genFileCopy [relfile|webSocket.ts|], - genWebSocketProvider spec - ] + sequence + [ genFileCopy [relfile|webSocket.ts|], + genWebSocketProvider spec + ] | otherwise = return [] where genFileCopy = return . C.mkSrcTmplFd diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/JsImport.hs b/waspc/src/Wasp/Generator/WebAppGenerator/JsImport.hs index 68332fa7dc..8e83c4c427 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/JsImport.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/JsImport.hs @@ -1,14 +1,12 @@ module Wasp.Generator.WebAppGenerator.JsImport where import qualified Data.Aeson as Aeson -import Data.Maybe (fromJust) import StrongPath (Dir, Path, Posix, Rel) -import qualified StrongPath as SP -import Wasp.AppSpec.ExtImport (ExtImport) +import StrongPath.TH (reldirP) +import Wasp.AppSpec.ExtImport (ExtImport (..)) import qualified Wasp.AppSpec.ExtImport as EI import qualified Wasp.Generator.JsImport as GJI import Wasp.Generator.WebAppGenerator.Common (WebAppSrcDir) -import Wasp.Generator.WebAppGenerator.ExternalCodeGenerator (extClientCodeDirInWebAppSrcDir) import Wasp.JsImport ( JsImport, JsImportIdentifier, @@ -24,6 +22,26 @@ extImportToImportJson pathFromImportLocationToSrcDir maybeExtImport = GJI.jsImpo where jsImport = extImportToJsImport pathFromImportLocationToSrcDir <$> maybeExtImport +-- extImportToImportJson :: +-- Path Posix (Rel importLocation) (Dir WebAppSrcDir) -> +-- Maybe ExtImport -> +-- Aeson.Value +-- extImportToImportJson _ maybeExtImport = case maybeExtImport of +-- Nothing -> object ["isDefined" .= False] +-- Just extImport -> makeImportObject extImport +-- where +-- makeImportObject (ExtImport importName importPath) = +-- let importClause = makeImportClause importName +-- importPathStr = "ext-sdrc/" ++ SP.toFilePath importPath +-- in object +-- [ "isDefined" .= True, +-- "importStatement" .= ("import " ++ importClause ++ "from \"" ++ importPathStr ++ "\""), +-- "importIdentifier" .= importName +-- ] +-- makeImportClause = \case +-- EI.ExtImportModule name -> name +-- EI.ExtImportField name -> "{ " ++ name ++ " + getJsImportStmtAndIdentifier :: Path Posix (Rel importLocation) (Dir WebAppSrcDir) -> EI.ExtImport -> @@ -36,4 +54,5 @@ extImportToJsImport :: JsImport extImportToJsImport = GJI.extImportToJsImport webAppExtDir where - webAppExtDir = fromJust (SP.relDirToPosix extClientCodeDirInWebAppSrcDir) + -- filip: read notes in ServerGenerator/JsImport.hs + webAppExtDir = [reldirP|../../../../src|] diff --git a/waspc/src/Wasp/JsImport.hs b/waspc/src/Wasp/JsImport.hs index 39a0db10f1..8ef9da26b4 100644 --- a/waspc/src/Wasp/JsImport.hs +++ b/waspc/src/Wasp/JsImport.hs @@ -10,6 +10,7 @@ module Wasp.JsImport makeJsImport, applyJsImportAlias, getJsImportStmtAndIdentifier, + getJsImportStmtAndIdentifierRaw, ) where @@ -34,6 +35,7 @@ data JsImport = JsImport type JsImportPath = Path Posix (Rel Dir') File' +-- Note (filip): not a fan of so many aliases for regular types type JsImportAlias = String data JsImportName @@ -60,15 +62,24 @@ applyJsImportAlias importAlias jsImport = jsImport {_importAlias = importAlias} getJsImportStmtAndIdentifier :: JsImport -> (JsImportStatement, JsImportIdentifier) getJsImportStmtAndIdentifier (JsImport importPath importName maybeImportAlias) = + getJsImportStmtAndIdentifierRaw normalizedPath importName maybeImportAlias + where + filePath = SP.fromRelFileP importPath + normalizedPath = if ".." `isPrefixOf` filePath then filePath else "./" ++ filePath + +-- filip: attempt to simplify how we generate imports. I wanted to generate a +-- module import (e.g., '@ext-src/something') and couldn't do it. This is one of +-- the funtions I implemented while I was trying to pull it off. +getJsImportStmtAndIdentifierRaw :: + FilePath -> + JsImportName -> + Maybe JsImportAlias -> + (JsImportStatement, JsImportIdentifier) +getJsImportStmtAndIdentifierRaw importPath importName maybeImportAlias = (importStatement, importIdentifier) where (importIdentifier, importClause) = jsImportIdentifierAndClause - - importStatement :: JsImportStatement - importStatement = "import " ++ importClause ++ " from '" ++ normalizedPath ++ "'" - where - filePath = SP.fromRelFileP importPath - normalizedPath = if ".." `isPrefixOf` filePath then filePath else "./" ++ filePath + importStatement = "import " ++ importClause ++ " from '" ++ importPath ++ "'" -- First part of import statement based on type of import and alias -- e.g. for import { Name as Alias } from "file.js" it returns ("Alias", "{ Name as Alias }") diff --git a/waspc/waspc.cabal b/waspc/waspc.cabal index d850292077..646ee5d478 100644 --- a/waspc/waspc.cabal +++ b/waspc/waspc.cabal @@ -292,6 +292,7 @@ library Wasp.Generator.Monad Wasp.Generator.NpmDependencies Wasp.Generator.NpmInstall + Wasp.Generator.SdkGenerator Wasp.Generator.ServerGenerator Wasp.Generator.ServerGenerator.JsImport Wasp.Generator.ServerGenerator.ApiRoutesG diff --git a/waspc/waspls/src/Wasp/LSP/ExtImport/Path.hs b/waspc/waspls/src/Wasp/LSP/ExtImport/Path.hs index d1c4882d96..a10fa90a7a 100644 --- a/waspc/waspls/src/Wasp/LSP/ExtImport/Path.hs +++ b/waspc/waspls/src/Wasp/LSP/ExtImport/Path.hs @@ -67,6 +67,7 @@ waspStylePathToCachePath (WaspStyleExtFilePath waspStylePath) = then ExtFileCachePath relPathWithoutExt (DotExact ext) else ExtFileCachePath relPathWithoutExt (widenExtension ext) where + -- Filip: todo - update for new structure useExactExtension = "@client" `isPrefixOf` waspStylePath absPathToCachePath :: HasProjectRootDir m => SP.Path' SP.Abs (SP.File a) -> m (Maybe ExtFileCachePath)