From 6f0714ad23bdb480e56de295a3669124529a85f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Sodi=C4=87?= Date: Mon, 29 Jan 2024 14:00:20 +0100 Subject: [PATCH] Create restructuring prototype (#1584) --- examples/streaming/src/client/vite-env.d.ts | 2 +- .../20240119141512_add_session/migration.sql | 13 - waspc/cli/src/Wasp/Cli/Common.hs | 6 +- waspc/data/Cli/templates/basic/package.json | 10 + .../templates/react-app/src/index.tsx | 2 +- .../templates/react-app/src/router.tsx | 2 +- .../templates/react-app/tsconfig.json | 12 +- .../templates/react-app/vite.config.ts | 3 + .../Generator/templates/sdk/dependencies.txt | 127 +++++++ .../data/Generator/templates/sdk/package.json | 36 ++ .../templates/sdk/wasp/api/events.ts | 11 + .../Generator/templates/sdk/wasp/api/index.ts | 104 ++++++ .../templates/sdk/wasp/auth/forms/Auth.tsx | 85 +++++ .../templates/sdk/wasp/auth/forms/Login.tsx | 17 + .../templates/sdk/wasp/auth/forms/Signup.tsx | 23 ++ .../sdk/wasp/auth/forms/internal/Form.tsx | 95 +++++ .../sdk/wasp/auth/forms/internal/Message.tsx | 18 + .../forms/internal/common/LoginSignupForm.tsx | 178 +++++++++ .../useUsernameAndPassword.ts | 29 ++ .../templates/sdk/wasp/auth/forms/types.ts | 39 ++ .../templates/sdk/wasp/auth/helpers/user.ts | 14 + .../Generator/templates/sdk/wasp/auth/jwt.ts | 12 + .../templates/sdk/wasp/auth/login.ts | 13 + .../templates/sdk/wasp/auth/logout.ts | 17 + .../templates/sdk/wasp/auth/lucia.ts | 55 +++ .../auth/pages/createAuthRequiredPage.jsx | 30 ++ .../templates/sdk/wasp/auth/password.ts | 15 + .../sdk/wasp/auth/providers/types.ts | 40 +++ .../templates/sdk/wasp/auth/session.ts | 107 ++++++ .../templates/sdk/wasp/auth/signup.ts | 9 + .../templates/sdk/wasp/auth/types.ts | 2 + .../templates/sdk/wasp/auth/useAuth.ts | 38 ++ .../Generator/templates/sdk/wasp/auth/user.ts | 27 ++ .../templates/sdk/wasp/auth/utils.ts | 302 ++++++++++++++++ .../templates/sdk/wasp/auth/validation.ts | 77 ++++ .../src => sdk/wasp}/core/AuthError.js | 0 .../src => sdk/wasp}/core/HttpError.js | 0 .../Generator/templates/sdk/wasp/core/auth.js | 41 +++ .../templates/sdk/wasp/core/config.js | 9 + .../sdk/wasp/core/stitches.config.js | 33 ++ .../templates/sdk/wasp/core/storage.ts | 50 +++ .../templates/sdk/wasp/entities/index.ts | 21 ++ .../templates/sdk/wasp/ext-src/actions.ts | 56 +++ .../templates/sdk/wasp/ext-src/queries.ts | 15 + .../templates/sdk/wasp/operations/index.ts | 22 ++ .../sdk/wasp/operations/resources.js | 81 +++++ .../sdk/wasp/operations/updateHandlersMap.js | 37 ++ .../templates/sdk/wasp/rpc/actions/core.d.ts | 13 + .../templates/sdk/wasp/rpc/actions/core.js | 37 ++ .../templates/sdk/wasp/rpc/actions/index.ts | 14 + .../Generator/templates/sdk/wasp/rpc/index.ts | 338 ++++++++++++++++++ .../templates/sdk/wasp/rpc/queries/core.d.ts | 23 ++ .../templates/sdk/wasp/rpc/queries/core.js | 30 ++ .../templates/sdk/wasp/rpc/queries/index.ts | 6 + .../templates/sdk/wasp/rpc/queryClient.ts | 33 ++ .../templates/sdk/wasp/server/_types/index.ts | 101 ++++++ .../sdk/wasp/server/_types/serialization.ts | 43 +++ .../sdk/wasp/server/_types/taggedEntities.ts | 22 ++ .../sdk/wasp/server/actions/index.ts | 39 ++ .../sdk/wasp/server/actions/types.ts | 34 ++ .../templates/sdk/wasp/server/dbClient.ts | 12 + .../sdk/wasp/server/queries/index.ts | 13 + .../sdk/wasp/server/queries/types.ts | 6 + .../templates/sdk/wasp/server/utils.ts | 67 ++++ .../templates/sdk/wasp/types/index.ts | 9 + .../templates/sdk/wasp/universal/types.ts | 31 ++ .../templates/sdk/wasp/universal/url.ts | 3 + .../Generator/templates/server/nodemon.json | 2 + .../Generator/templates/server/package.json | 3 +- .../Generator/templates/server/src/app.js | 2 +- .../providers/email/requestPasswordReset.ts | 2 +- .../src/auth/providers/email/resetPassword.ts | 2 +- .../server/src/auth/providers/email/signup.ts | 2 +- .../src/auth/providers/email/verifyEmail.ts | 2 +- .../templates/server/src/auth/utils.ts | 4 +- .../templates/server/src/auth/validation.ts | 2 +- .../Generator/templates/server/tsconfig.json | 16 +- .../crud-testing/src/server/auth_simple.js | 2 +- waspc/examples/todo-typescript/.gitignore | 4 + waspc/examples/todo-typescript/.wasproot | 1 + waspc/examples/todo-typescript/main.wasp | 75 ++++ waspc/examples/todo-typescript/migrate | 6 + .../20240119151915_init}/migration.sql | 14 + .../migrations/migration_lock.toml | 0 waspc/examples/todo-typescript/package.json | 12 + .../examples/todo-typescript/src/.waspignore | 3 + waspc/examples/todo-typescript/src/Main.css | 73 ++++ .../examples/todo-typescript/src/MainPage.tsx | 107 ++++++ .../todo-typescript/src/task/actions.ts | 56 +++ .../todo-typescript/src/task/queries.ts | 15 + .../todo-typescript/src/user/LoginPage.tsx | 17 + .../todo-typescript/src/user/SignupPage.tsx | 17 + .../todo-typescript/src/vite-env.d.ts | 1 + .../examples/todo-typescript/src/waspLogo.png | Bin 0 -> 24877 bytes waspc/examples/todo-typescript/tsconfig.json | 28 ++ .../Evaluation/TypedExpr/Combinators.hs | 16 +- waspc/src/Wasp/Generator.hs | 2 + .../Generator/ExternalCodeGenerator/Js.hs | 7 +- waspc/src/Wasp/Generator/JsImport.hs | 16 + waspc/src/Wasp/Generator/SdkGenerator.hs | 90 +++++ waspc/src/Wasp/Generator/ServerGenerator.hs | 9 +- .../Generator/ServerGenerator/JsImport.hs | 9 +- waspc/src/Wasp/Generator/WebAppGenerator.hs | 29 +- .../Generator/WebAppGenerator/JsImport.hs | 29 +- waspc/src/Wasp/JsImport.hs | 23 +- waspc/waspc.cabal | 1 + waspc/waspls/src/Wasp/LSP/ExtImport/Path.hs | 1 + 107 files changed, 3422 insertions(+), 87 deletions(-) delete mode 100644 examples/todo-typescript/migrations/20240119141512_add_session/migration.sql create mode 100644 waspc/data/Cli/templates/basic/package.json create mode 100644 waspc/data/Generator/templates/sdk/dependencies.txt create mode 100644 waspc/data/Generator/templates/sdk/package.json create mode 100644 waspc/data/Generator/templates/sdk/wasp/api/events.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/api/index.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/forms/Auth.tsx create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/forms/Login.tsx create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/forms/Signup.tsx create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/Form.tsx create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/Message.tsx create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/usernameAndPassword/useUsernameAndPassword.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/forms/types.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/helpers/user.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/jwt.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/login.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/logout.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/lucia.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/pages/createAuthRequiredPage.jsx create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/password.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/providers/types.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/session.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/signup.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/types.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/useAuth.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/user.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/utils.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/validation.ts rename waspc/data/Generator/templates/{server/src => sdk/wasp}/core/AuthError.js (100%) rename waspc/data/Generator/templates/{server/src => sdk/wasp}/core/HttpError.js (100%) create mode 100644 waspc/data/Generator/templates/sdk/wasp/core/auth.js create mode 100644 waspc/data/Generator/templates/sdk/wasp/core/config.js create mode 100644 waspc/data/Generator/templates/sdk/wasp/core/stitches.config.js create mode 100644 waspc/data/Generator/templates/sdk/wasp/core/storage.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/entities/index.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/ext-src/actions.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/ext-src/queries.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/operations/index.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/operations/resources.js create mode 100644 waspc/data/Generator/templates/sdk/wasp/operations/updateHandlersMap.js create mode 100644 waspc/data/Generator/templates/sdk/wasp/rpc/actions/core.d.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/rpc/actions/core.js create mode 100644 waspc/data/Generator/templates/sdk/wasp/rpc/actions/index.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/rpc/index.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/rpc/queries/core.d.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/rpc/queries/core.js create mode 100644 waspc/data/Generator/templates/sdk/wasp/rpc/queries/index.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/rpc/queryClient.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/server/_types/index.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/server/_types/serialization.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/server/_types/taggedEntities.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/server/actions/index.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/server/actions/types.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/server/dbClient.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/server/queries/index.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/server/queries/types.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/server/utils.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/types/index.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/universal/types.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/universal/url.ts create mode 100644 waspc/examples/todo-typescript/.gitignore create mode 100644 waspc/examples/todo-typescript/.wasproot create mode 100644 waspc/examples/todo-typescript/main.wasp create mode 100755 waspc/examples/todo-typescript/migrate rename {examples/todo-typescript/migrations/20231214130914_new_auth => waspc/examples/todo-typescript/migrations/20240119151915_init}/migration.sql (72%) rename {examples => waspc/examples}/todo-typescript/migrations/migration_lock.toml (100%) create mode 100644 waspc/examples/todo-typescript/package.json create mode 100644 waspc/examples/todo-typescript/src/.waspignore create mode 100644 waspc/examples/todo-typescript/src/Main.css create mode 100644 waspc/examples/todo-typescript/src/MainPage.tsx create mode 100644 waspc/examples/todo-typescript/src/task/actions.ts create mode 100644 waspc/examples/todo-typescript/src/task/queries.ts create mode 100644 waspc/examples/todo-typescript/src/user/LoginPage.tsx create mode 100644 waspc/examples/todo-typescript/src/user/SignupPage.tsx create mode 100644 waspc/examples/todo-typescript/src/vite-env.d.ts create mode 100644 waspc/examples/todo-typescript/src/waspLogo.png create mode 100644 waspc/examples/todo-typescript/tsconfig.json create mode 100644 waspc/src/Wasp/Generator/SdkGenerator.hs 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 0000000000000000000000000000000000000000..d39a9443a8153b158b76f51dda2e42f3b34a9169 GIT binary patch literal 24877 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 \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)