├── .nvmrc ├── patches ├── .gitkeep ├── @hebilicious__vue-query-nuxt@0.3.0.patch ├── ts-plugin-sort-import-suggestions.patch ├── eslint-plugin-codegen@0.17.0.patch └── typescript.patch ├── api ├── .template.env.local ├── wallaby.cjs ├── test │ ├── setup.ts │ ├── some.test.ts │ ├── auto-imports.d.ts │ └── setup2.ts ├── src │ ├── resources │ │ ├── lib │ │ │ ├── schema.ts │ │ │ └── req.ts │ │ ├── views.ts │ │ ├── lib.ts │ │ ├── Accounts.ts │ │ ├── views │ │ │ ├── UserView.ts │ │ │ └── PostView.ts │ │ ├── Users.ts │ │ ├── Events.ts │ │ ├── HelloWorld.ts │ │ ├── Blog.ts │ │ ├── resolvers │ │ │ └── UserResolver.ts │ │ └── Operations.ts │ ├── lib │ │ ├── logger.ts │ │ ├── middleware │ │ │ └── events.ts │ │ ├── layers.ts │ │ ├── basicRuntime.ts │ │ ├── routing.ts │ │ ├── middleware.ts │ │ └── observability.ts │ ├── config.ts │ ├── services │ │ ├── DBContext.ts │ │ ├── lib.ts │ │ ├── Events.ts │ │ ├── UserProfile.ts │ │ └── DBContext │ │ │ ├── BlogPostRepo.ts │ │ │ └── UserRepo.ts │ ├── services.ts │ ├── Accounts.controllers.ts │ ├── api.ts │ ├── controllers.ts │ ├── Operations.controllers.ts │ ├── resources.ts │ ├── Users.controllers.ts │ ├── models │ │ ├── Blog.ts │ │ └── User.ts │ ├── main.ts │ ├── HelloWorld.controllers.ts │ ├── config │ │ ├── base.ts │ │ └── api.ts │ ├── Blog.controllers.ts │ └── router.ts ├── .madgerc ├── vitest.config.ts ├── nodemon.json ├── vite.config.ts ├── tsconfig.test.local.json ├── eslint.config.mjs ├── tsconfig.json ├── tsconfig.test.json ├── tsconfig.src.json └── package.json ├── e2e ├── helpers │ ├── @types │ │ ├── selectors.d.ts │ │ └── enhanced-selectors.d.ts │ ├── runtime.ts │ ├── fillInputs.ts │ └── shared.ts ├── .gitignore ├── playwright │ ├── types.ts │ └── util.ts ├── README.md ├── scripts │ └── extract.sh ├── tests │ └── home.spec.ts ├── eslint.config.mjs ├── tsconfig.json ├── playwright.config.ts ├── global-setup.ts.bak └── package.json ├── frontend ├── _types │ ├── vue-timeago3.d.ts │ └── http-proxy-node16.d.ts ├── .gitignore ├── assets │ └── variables.scss ├── server │ ├── routes │ │ ├── logout.ts │ │ ├── .version.ts │ │ ├── login │ │ │ └── [userId].ts │ │ └── .readiness.ts │ ├── tsconfig.json │ ├── middleware │ │ └── basicAuth.ts │ └── plugins │ │ └── proxy.ts ├── composables │ ├── form.ts │ ├── bus.ts │ ├── currentUser.ts │ ├── onMountedWithCleanup.ts │ ├── eventsource.ts │ ├── useRouteParams.ts │ ├── client.ts │ ├── useModelWrapper.ts │ └── intl.ts ├── .prettierrc ├── tsconfig.test.json ├── middleware │ └── auth.ts ├── plugins │ ├── timeago.ts │ ├── query.ts │ ├── toastification.ts │ ├── sentry.ts │ ├── vuetify.ts │ └── runtime.ts ├── app.vue ├── components │ ├── Delayed.vue │ ├── TextField.vue │ └── QueryResult.vue ├── tsconfig.json ├── declarations.d.ts ├── eslint.config.mjs ├── README.md ├── pages │ ├── blog │ │ ├── index.vue │ │ └── [id].vue │ └── index.vue ├── layouts │ └── default.vue ├── package.json ├── nuxt.config.ts └── utils │ └── observability.ts ├── types └── modules.d.ts ├── doc └── img │ └── data-arch.png ├── pnpm-workspace.yaml ├── .devcontainer ├── humanlog.sh ├── devcontainer.json ├── usepnpm.sh └── Dockerfile ├── tsconfig.base.json ├── .vscode ├── extensions.json ├── react.code-snippets ├── service.code-snippets ├── operators.code-snippets ├── launch.json ├── settings.json ├── model.code-snippets └── tasks.json ├── Dockerfile.dockerignore ├── Dockerfile.fe.dockerignore ├── wallaby.base.cjs ├── .ncurc.json ├── .npmrc ├── tsconfig.all.json ├── tsconfig.src.json ├── scripts ├── humanlog.sh ├── extract.sh └── clean-dist.sh ├── tsconfig.plugins.json ├── .gitignore ├── Dockerfile ├── README.md ├── Dockerfile.fe ├── vitest.workspace.ts ├── vite.config.base.ts ├── eslint.vue.config.mjs ├── vite.config.test.ts ├── package.json └── eslint.base.config.mjs /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.10.0 -------------------------------------------------------------------------------- /patches/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/.template.env.local: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/wallaby.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("../wallaby.base.cjs") 2 | -------------------------------------------------------------------------------- /e2e/helpers/@types/selectors.d.ts: -------------------------------------------------------------------------------- 1 | export type Selectors = "TODO" 2 | -------------------------------------------------------------------------------- /frontend/_types/vue-timeago3.d.ts: -------------------------------------------------------------------------------- 1 | declare module "vue-timeago3" 2 | -------------------------------------------------------------------------------- /e2e/.gitignore: -------------------------------------------------------------------------------- 1 | test-results 2 | storageState.*.json 3 | test-out/ 4 | -------------------------------------------------------------------------------- /api/test/setup.ts: -------------------------------------------------------------------------------- 1 | import "#api/basicRuntime" 2 | 3 | import "./setup2.js" 4 | -------------------------------------------------------------------------------- /types/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'express-compression'; 2 | declare module 'tcp-port-used'; -------------------------------------------------------------------------------- /doc/img/data-arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/effect-app/sample/HEAD/doc/img/data-arch.png -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - api 3 | - frontend 4 | - e2e 5 | #- libs/packages/* # link 6 | -------------------------------------------------------------------------------- /api/src/resources/lib/schema.ts: -------------------------------------------------------------------------------- 1 | export { Req } from "./req.js" 2 | 3 | export * from "effect-app/Schema" 4 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | .nuxt 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | dist 9 | -------------------------------------------------------------------------------- /.devcontainer/humanlog.sh: -------------------------------------------------------------------------------- 1 | export HUMANLOG_INSTALL="/home/vscode/.humanlog" 2 | export PATH="$HUMANLOG_INSTALL/bin:$PATH" -------------------------------------------------------------------------------- /api/.madgerc: -------------------------------------------------------------------------------- 1 | { 2 | "detectiveOptions": { 3 | "ts": { 4 | "skipTypeImports": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /api/src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import { makeLog } from "effect-app/utils" 2 | 3 | export const AppLogger = makeLog("app", "info") 4 | -------------------------------------------------------------------------------- /api/test/some.test.ts: -------------------------------------------------------------------------------- 1 | import { HelloWorldRsc } from "#resources" 2 | 3 | it("works", () => { 4 | console.log(HelloWorldRsc) 5 | }) 6 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "./node_modules/effect-app/tsconfig.base.json", 4 | "./tsconfig.plugins.json" 5 | ] 6 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "editorconfig.editorconfig", 5 | "Vue.volar" 6 | ] 7 | } -------------------------------------------------------------------------------- /frontend/_types/http-proxy-node16.d.ts: -------------------------------------------------------------------------------- 1 | declare module "http-proxy-node16" { 2 | import * as Server from "http-proxy" 3 | 4 | export = Server 5 | } 6 | -------------------------------------------------------------------------------- /e2e/playwright/types.ts: -------------------------------------------------------------------------------- 1 | import type { Locator } from "@playwright/test" 2 | 3 | export interface LocatorAble { 4 | locator(selector: string): Locator 5 | } 6 | -------------------------------------------------------------------------------- /frontend/assets/variables.scss: -------------------------------------------------------------------------------- 1 | $grid-breakpoints: ( 2 | 'xs': 0, 3 | 'sm': 340px, 4 | 'md': 540px, 5 | 'lg': 800px - 24px, 6 | 'xl': 1280px - 24px 7 | ); -------------------------------------------------------------------------------- /frontend/server/routes/logout.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(event => { 2 | deleteCookie(event, "user-id") 3 | return sendRedirect(event, "/") 4 | }) 5 | -------------------------------------------------------------------------------- /api/vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import defineTestConfig from "../vite.config.test" 3 | 4 | export default defineTestConfig(__dirname) 5 | -------------------------------------------------------------------------------- /frontend/composables/form.ts: -------------------------------------------------------------------------------- 1 | export { 2 | convertIn, 3 | convertOut, 4 | type FieldInfo, 5 | buildFieldInfoFromFields, 6 | } from "@effect-app/vue/form" 7 | -------------------------------------------------------------------------------- /api/src/config.ts: -------------------------------------------------------------------------------- 1 | // codegen:start {preset: barrel, include: config/*.ts } 2 | export * from "./config/api.js" 3 | export * from "./config/base.js" 4 | // codegen:end 5 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "semi": false, 4 | "singleQuote": false, 5 | "tabWidth": 2, 6 | "htmlWhitespaceSensitivity": "ignore" 7 | } -------------------------------------------------------------------------------- /frontend/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": [ 4 | "vitest/globals", 5 | "node" 6 | ] 7 | }, 8 | "extends": "./tsconfig.json", 9 | } -------------------------------------------------------------------------------- /api/src/resources/views.ts: -------------------------------------------------------------------------------- 1 | // codegen:start {preset: barrel, include: ./views/*.ts} 2 | export * from "./views/PostView.js" 3 | export * from "./views/UserView.js" 4 | // codegen:end 5 | -------------------------------------------------------------------------------- /frontend/server/routes/.version.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => { 2 | const config = useRuntimeConfig() 3 | return { 4 | version: config.public.feVersion, 5 | } 6 | }) 7 | -------------------------------------------------------------------------------- /frontend/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(_ => { 2 | const userId = getUserId() 3 | 4 | if (!userId.value) { 5 | return navigateTo("/login") 6 | } 7 | }) 8 | -------------------------------------------------------------------------------- /api/src/services/DBContext.ts: -------------------------------------------------------------------------------- 1 | // codegen:start {preset: barrel, include: ./DBContext/* } 2 | export * from "./DBContext/BlogPostRepo.js" 3 | export * from "./DBContext/UserRepo.js" 4 | // codegen:end 5 | -------------------------------------------------------------------------------- /frontend/server/routes/login/[userId].ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(event => { 2 | setCookie(event, "user-id", event.context.params!["userId"]!) 3 | return sendRedirect(event, "/") 4 | }) 5 | -------------------------------------------------------------------------------- /api/src/resources/lib.ts: -------------------------------------------------------------------------------- 1 | // codegen:start {preset: barrel, include: ./lib/*.ts, exclude: ./lib/schema.ts} 2 | export * from "./lib/req.js" 3 | // codegen:end 4 | 5 | export * as S from "./lib/schema.js" 6 | -------------------------------------------------------------------------------- /frontend/plugins/timeago.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin } from "nuxt/app" 2 | import timeago from "vue-timeago3" 3 | 4 | export default defineNuxtPlugin(nuxtApp => { 5 | nuxtApp.vueApp.use(timeago) 6 | }) 7 | -------------------------------------------------------------------------------- /api/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [], 3 | "ext": "ts,js,env,local", 4 | "watch": [ 5 | "../*/src/**/*", 6 | "./src/**/*", 7 | ".env", 8 | ".env.local" 9 | ], 10 | "delay": 333 11 | } -------------------------------------------------------------------------------- /.vscode/react.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "FormattedMessage": { 3 | "prefix": "fom", 4 | "body": [ 5 | "" 6 | ] 7 | } 8 | } -------------------------------------------------------------------------------- /Dockerfile.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | .data 3 | .git 4 | .vscode 5 | .idea 6 | Dockerfile 7 | Dockerfile.* 8 | node_modules 9 | **/node_modules 10 | *.log 11 | docs 12 | #**/dist 13 | **/.env.local 14 | frontend 15 | -------------------------------------------------------------------------------- /frontend/app.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/composables/bus.ts: -------------------------------------------------------------------------------- 1 | import type { ClientEvents } from "#resources" 2 | 3 | import mitt from "mitt" 4 | 5 | type Events = { 6 | serverEvents: ClientEvents 7 | } 8 | 9 | export const bus = mitt() 10 | -------------------------------------------------------------------------------- /frontend/plugins/query.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin } from "nuxt/app" 2 | import { VueQueryPlugin } from "@tanstack/vue-query" 3 | 4 | export default defineNuxtPlugin(nuxtApp => { 5 | nuxtApp.vueApp.use(VueQueryPlugin) 6 | }) 7 | -------------------------------------------------------------------------------- /Dockerfile.fe.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | .data 3 | .git 4 | .vscode 5 | .idea 6 | Dockerfile 7 | Dockerfile.* 8 | # node_modules 9 | # **/node_modules 10 | *.log 11 | docs 12 | #**/dist 13 | **/.env.local 14 | #!frontend/node_modules -------------------------------------------------------------------------------- /wallaby.base.cjs: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | return { 3 | autoDetect: true, 4 | // right now a limitation 5 | // as it runs out of memory, probably from running their own compiler instance 6 | runMode: 'onsave' 7 | } 8 | } -------------------------------------------------------------------------------- /e2e/README.md: -------------------------------------------------------------------------------- 1 | # E2E 2 | 3 | ### Update Selectors type 4 | 5 | `sh ../scripts/extract.sh` 6 | 7 | If there are dynamic selectors, e.g `upgrade-table-${MachineUpgradeStates.Tag}`, you can manage them in the `EnhancedTags` type in `commands.ts`. 8 | -------------------------------------------------------------------------------- /api/vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from "vite" 3 | import makeConfig from "../vite.config.base" 4 | 5 | const cfg = makeConfig(__dirname) 6 | // console.log("cfg", cfg) 7 | export default defineConfig(cfg) 8 | -------------------------------------------------------------------------------- /api/src/services.ts: -------------------------------------------------------------------------------- 1 | // codegen:start {preset: barrel, include: services/*.ts } 2 | export * from "./services/DBContext.js" 3 | export * from "./services/Events.js" 4 | export * from "./services/lib.js" 5 | export * from "./services/UserProfile.js" 6 | // codegen:end 7 | -------------------------------------------------------------------------------- /.ncurc.json: -------------------------------------------------------------------------------- 1 | { 2 | "reject": [ 3 | "jwks-rsa", 4 | "applicationinsights", 5 | "faker", 6 | "@types/faker", 7 | "redis", 8 | "@types/redis", 9 | "eslint-plugin-codegen", 10 | "vue-toastification", 11 | "@opentelemetry/*" 12 | ] 13 | } -------------------------------------------------------------------------------- /e2e/scripts/extract.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | SRC_SELECTORS=$(grep -hro 'data-test="[^"]*"' ../frontend/src | cut -d \" -f2 | sort | uniq) 4 | echo $SRC_SELECTORS | sed 's/ /"\n | "/g; s/^/&export type Selectors =\n | "/; s/.$/&"/;' | cat > helpers/@types/selectors.d.ts 5 | -------------------------------------------------------------------------------- /frontend/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://v3.nuxtjs.org/concepts/typescript 3 | "extends": "../.nuxt/tsconfig.server.json", 4 | "compilerOptions": { 5 | "lib": [ 6 | "DOM", 7 | "dom.iterable", 8 | "ES2022" 9 | ], 10 | "strict": true 11 | }, 12 | } -------------------------------------------------------------------------------- /frontend/components/Delayed.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=*change-case* 2 | public-hoist-pattern[]=*vue* 3 | 4 | # fix problems with linking in the libs repo 5 | shamefully-hoist=true 6 | # node-linker=hoisted 7 | 8 | link-workspace-packages=deep 9 | prefer-workspace-packages=true 10 | #shared-workspace-shrinkwrap=true 11 | -------------------------------------------------------------------------------- /api/src/services/lib.ts: -------------------------------------------------------------------------------- 1 | export * from "@effect-app/infra/adapters/memQueue" 2 | export * from "@effect-app/infra/adapters/ServiceBus" 3 | export * from "@effect-app/infra/Emailer" 4 | export * from "@effect-app/infra/Operations" 5 | export * from "@effect-app/infra/Store/index" 6 | 7 | export { Q } from "@effect-app/infra/Model" 8 | -------------------------------------------------------------------------------- /api/tsconfig.test.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.test.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "./test/dist", 6 | "tsBuildInfoFile": "./test/dist/.local.tsbuildinfo", 7 | "noEmit": true, 8 | }, 9 | "include": [ 10 | "./test/**/*.ts", 11 | "./src/**/*.ts" 12 | ] 13 | } -------------------------------------------------------------------------------- /frontend/composables/currentUser.ts: -------------------------------------------------------------------------------- 1 | // Naive login, good enough for the start 2 | 3 | import type { UserId } from "#models/User" 4 | 5 | export function getUserId() { 6 | return useCookie("user-id") 7 | } 8 | 9 | export function login(userId: UserId) { 10 | return $fetch(`/login/${userId}`, { credentials: "include" }) 11 | } 12 | -------------------------------------------------------------------------------- /e2e/tests/home.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test" 2 | 3 | test("Can visit the page", async ({ page }) => { 4 | await page.goto(`/`) 5 | // await expect(page.click("text=@effect-app/boilerplate")).toBeVisible() 6 | await expect(page.locator("text=randomUser")).toBeVisible() 7 | // await page.click("text=@effect-app/boilerplate") 8 | }) 9 | -------------------------------------------------------------------------------- /.vscode/service.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "ServiceConstructor": { 3 | "prefix": "svc", 4 | "body": [ 5 | "export class $1 extends Effect.Service<$1>()(\"$1\", {", 6 | " dependencies: [$3],", 7 | " effect: Effect.gen(function*() {", 8 | " $2", 9 | " return {}", 10 | " })", 11 | "}) {}", 12 | ] 13 | } 14 | } -------------------------------------------------------------------------------- /frontend/plugins/toastification.ts: -------------------------------------------------------------------------------- 1 | import Toast from "vue-toastification" 2 | 3 | // Import the CSS or use your own! 4 | import "vue-toastification/dist/index.css" 5 | 6 | export default defineNuxtPlugin(nuxtApp => { 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | nuxtApp.vueApp.use("default" in Toast ? (Toast as any).default : Toast, {}) 9 | }) 10 | -------------------------------------------------------------------------------- /api/src/Accounts.controllers.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "#lib/routing" 2 | import { AccountsRsc } from "#resources" 3 | import { UserRepo } from "#services" 4 | 5 | export default Router(AccountsRsc)({ 6 | dependencies: [UserRepo.Default], 7 | *effect(match) { 8 | const userRepo = yield* UserRepo 9 | 10 | return match({ 11 | GetMe: userRepo.getCurrentUser 12 | }) 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /api/src/resources/Accounts.ts: -------------------------------------------------------------------------------- 1 | import { User } from "#models/User" 2 | import { NotFoundError } from "effect-app/client/errors" 3 | import { S } from "./lib.js" 4 | 5 | export class GetMe extends S.Req()("GetMe", {}, { success: User, failure: NotFoundError }) {} 6 | 7 | // codegen:start {preset: meta, sourcePrefix: src/resources/} 8 | export const meta = { moduleName: "Accounts" } as const 9 | // codegen:end 10 | -------------------------------------------------------------------------------- /tsconfig.all.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": [], 4 | "exclude": [ 5 | "**/node_modules", 6 | "**/build", 7 | "**/lib", 8 | "**/dist", 9 | "**/.*" 10 | ], 11 | "references": [ 12 | { 13 | "path": "api" 14 | }, 15 | // { 16 | // "path": "frontend-vue" 17 | // }, 18 | // { 19 | // "path": "frontend-react" 20 | // }, 21 | ] 22 | } -------------------------------------------------------------------------------- /e2e/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { augmentedConfig } from "../eslint.base.config.mjs" 2 | 3 | import path from "node:path" 4 | import { fileURLToPath } from "node:url" 5 | 6 | const __filename = fileURLToPath(import.meta.url) 7 | const __dirname = path.dirname(__filename) 8 | 9 | export default [ 10 | ...augmentedConfig(__dirname, false, "tsconfig.all2.json"), 11 | { 12 | ignores: [ 13 | "eslint.config.mjs" 14 | ] 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /tsconfig.src.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": [], 4 | "exclude": [ 5 | "**/node_modules", 6 | "**/build", 7 | "**/lib", 8 | "**/dist", 9 | "**/.*" 10 | ], 11 | "references": [ 12 | { 13 | "path": "api/tsconfig.src.json" 14 | } 15 | // { 16 | // "path": "frontend-vue" 17 | // }, 18 | // { 19 | // "path": "frontend-react" 20 | // }, 21 | ] 22 | } -------------------------------------------------------------------------------- /api/src/lib/middleware/events.ts: -------------------------------------------------------------------------------- 1 | import { ClientEvents } from "#resources" 2 | import { Events } from "#services" 3 | import { makeSSE } from "@effect-app/infra/api/middlewares" 4 | import { Effect } from "effect-app" 5 | 6 | export const makeEvents = Effect.gen(function*() { 7 | const events = yield* Events 8 | return Effect.gen(function*() { 9 | const stream = yield* events.stream 10 | return yield* makeSSE(ClientEvents)(stream) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /frontend/server/routes/.readiness.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async event => { 2 | const config = useRuntimeConfig() 3 | const r = await Promise.all([ 4 | fetch(`${config.apiRoot}/.well-known/local/server-health`), 5 | ]) 6 | if (r.some(_ => !_.ok)) { 7 | console.error("$$$ readiness check failed", r) 8 | event.node.res.statusCode = 503 9 | } else { 10 | event.node.res.statusCode = 200 11 | } 12 | event.node.res.end() 13 | }) 14 | -------------------------------------------------------------------------------- /e2e/helpers/@types/enhanced-selectors.d.ts: -------------------------------------------------------------------------------- 1 | import type { Selectors } from "./selectors" 2 | 3 | // function untag(a: T & UnionBrand) :T 4 | // const POTab = untag(null as Id) 5 | // const PJTab = untag(null as Id) 6 | 7 | // type Id = UnionBrand & T 8 | 9 | export type EnhancedSelectors = Selectors // TODO | custom ones 10 | 11 | export type TestSelector = EnhancedSelectors | `${EnhancedSelectors}${` ` | `:`}${string}` 12 | -------------------------------------------------------------------------------- /api/src/api.ts: -------------------------------------------------------------------------------- 1 | // import { writeOpenapiDocsI } from "@effect-app/infra/api/writeDocs" 2 | import { Layer } from "effect-app" 3 | import * as controllers from "./controllers.js" 4 | import { HttpServerLive } from "./lib/layers.js" 5 | import { matchAll } from "./lib/routing.js" 6 | import { makeHttpServer } from "./router.js" 7 | 8 | const router = matchAll(controllers, Layer.empty) 9 | 10 | export const api = makeHttpServer(router) 11 | .pipe( 12 | Layer.provide(HttpServerLive) 13 | ) 14 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Effect App Boilerplate Codespace", 3 | "build": { 4 | "dockerfile": "Dockerfile" 5 | }, 6 | // see https://containers.dev/implementors/json_reference/#remoteUser 7 | // you can change the user name to whatever you want, this is used to avoid installing stuff in the image as root 8 | "remoteUser": "vscode", 9 | "postCreateCommand": "pnpm i && pnpm build", 10 | "hostRequirements": { 11 | "cpus": 4, 12 | "memory": "8gb" 13 | } 14 | } -------------------------------------------------------------------------------- /scripts/humanlog.sh: -------------------------------------------------------------------------------- 1 | if which humanlog >/dev/null; then 2 | # For some reason --skip-unchanged is enabled by default and can't figure out how to disable! 3 | # https://github.com/humanlogio/humanlog/issues/40 4 | tee dev.log | (humanlog --keep requestId --keep requestName --truncate-length 16384 || (echo 'humanlog crashed!' && cat)) 5 | else 6 | echo "!! It's recommended to install humanlog, to make these logs more readable. !!" 7 | # while read x ; do echo $x ; done 8 | tee dev.log 9 | fi 10 | -------------------------------------------------------------------------------- /api/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import path from "node:path" 2 | import { fileURLToPath } from "node:url" 3 | import { augmentedConfig } from "../eslint.base.config.mjs" 4 | 5 | const __filename = fileURLToPath(import.meta.url) 6 | const __dirname = path.dirname(__filename) 7 | 8 | export default [ 9 | ...augmentedConfig(__dirname, false), 10 | { 11 | ignores: [ 12 | "dist/**", 13 | "node_modules/**", 14 | "coverage/**", 15 | "**/*.d.ts", 16 | "**/*.config.ts" 17 | ] 18 | } 19 | ] 20 | -------------------------------------------------------------------------------- /patches/@hebilicious__vue-query-nuxt@0.3.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/package.json b/package.json 2 | index 0f613cc5f4edddec8e922d38d0c62467533667a2..ab3fa6cccd23a64ca41f3b698b13268455f86ea1 100644 3 | --- a/package.json 4 | +++ b/package.json 5 | @@ -60,7 +60,6 @@ 6 | "scripts": { 7 | "readme": "bun scripts/readme.ts", 8 | "prebuild": "bun postinstall", 9 | - "postinstall": "nuxi prepare", 10 | "postbuild": "bun readme", 11 | "build:stub": "nuxt-build-module --stub", 12 | "build:module": "nuxt-build-module", 13 | -------------------------------------------------------------------------------- /api/src/resources/views/UserView.ts: -------------------------------------------------------------------------------- 1 | import { User } from "#models/User" 2 | import { S } from "#resources/lib" 3 | 4 | export class UserView extends S.ExtendedClass()({ 5 | ...User.pick("id", "role"), 6 | displayName: S.NonEmptyString2k 7 | }) {} 8 | 9 | // codegen:start {preset: model} 10 | // 11 | /* eslint-disable */ 12 | export namespace UserView { 13 | export interface Encoded extends S.Struct.Encoded {} 14 | } 15 | /* eslint-enable */ 16 | // 17 | // codegen:end 18 | // 19 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://v3.nuxtjs.org/concepts/typescript 3 | "extends": [ 4 | "@tsconfig/strictest/tsconfig.json", 5 | "./.nuxt/tsconfig.json", 6 | "../tsconfig.plugins.json" 7 | ], 8 | "compilerOptions": { 9 | "lib": [ 10 | "DOM", 11 | "dom.iterable", 12 | "ES2022" 13 | ], 14 | "strict": true, 15 | "noUncheckedIndexedAccess": true, 16 | // This is not desirable, as it's useful in generators. 17 | "noImplicitReturns": false, 18 | "checkJs": false 19 | } 20 | } -------------------------------------------------------------------------------- /api/src/controllers.ts: -------------------------------------------------------------------------------- 1 | // codegen:start {preset: barrel, include: ./*.controllers.ts, import: default} 2 | import accountsControllers from "./Accounts.controllers.js" 3 | import blogControllers from "./Blog.controllers.js" 4 | import helloWorldControllers from "./HelloWorld.controllers.js" 5 | import operationsControllers from "./Operations.controllers.js" 6 | import usersControllers from "./Users.controllers.js" 7 | 8 | export { accountsControllers, blogControllers, helloWorldControllers, operationsControllers, usersControllers } 9 | // codegen:end 10 | -------------------------------------------------------------------------------- /api/src/resources/Users.ts: -------------------------------------------------------------------------------- 1 | import { UserId } from "#models/User" 2 | import { S } from "./lib.js" 3 | import { UserView } from "./views/UserView.js" 4 | 5 | export class IndexUsers extends S.Req()("IndexUsers", { 6 | filterByIds: S.NonEmptyArray(UserId) 7 | }, { 8 | allowAnonymous: true, 9 | allowRoles: ["user"], 10 | success: S.Struct({ 11 | users: S.Array(UserView) 12 | }) 13 | }) {} 14 | 15 | // codegen:start {preset: meta, sourcePrefix: src/resources/} 16 | export const meta = { moduleName: "Users" } as const 17 | // codegen:end 18 | -------------------------------------------------------------------------------- /.devcontainer/usepnpm.sh: -------------------------------------------------------------------------------- 1 | NPM_PATH=$(which npm) 2 | npm () { 3 | if [ -e pnpm-lock.yaml ] 4 | then 5 | echo "Please use PNPM with this project" 6 | elif [ -e yarn.lock ] 7 | then 8 | echo "Please use Yarn with this project" 9 | else 10 | $NPM_PATH "$@" 11 | fi 12 | } 13 | 14 | 15 | YARN_PATH=$(which yarn) 16 | yarn () { 17 | if [ -e pnpm-lock.yaml ] 18 | then 19 | echo "Please use PNPM with this project" 20 | elif [ -e package-lock.json ] 21 | then 22 | echo "Please use NPM with this project" 23 | else 24 | $YARN_PATH "$@" 25 | fi 26 | } -------------------------------------------------------------------------------- /frontend/declarations.d.ts: -------------------------------------------------------------------------------- 1 | // LOL https://nuxt.com/blog/v3-13#vue-typescript-changes 2 | import type { 3 | ComponentCustomOptions as _ComponentCustomOptions, 4 | ComponentCustomProperties as _ComponentCustomProperties, 5 | } from "vue" 6 | 7 | declare module "@vue/runtime-core" { 8 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 9 | interface ComponentCustomProperties extends _ComponentCustomProperties {} 10 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 11 | interface ComponentCustomOptions extends _ComponentCustomOptions {} 12 | } 13 | -------------------------------------------------------------------------------- /api/src/Operations.controllers.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "#lib/routing" 2 | import { OperationsRsc } from "#resources" 3 | import { Operations } from "#services" 4 | import { Effect } from "effect-app" 5 | import { OperationsDefault } from "./lib/layers.js" 6 | 7 | export default Router(OperationsRsc)({ 8 | dependencies: [OperationsDefault], 9 | *effect(match) { 10 | const operations = yield* Operations 11 | 12 | return match({ 13 | FindOperation: ({ id }) => 14 | operations 15 | .find(id) 16 | .pipe(Effect.andThen((_) => _.value ?? null)) 17 | }) 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /frontend/composables/onMountedWithCleanup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-function-type */ 2 | /** 3 | * A convenience wrapper for onMounted and onUnmounted, the returned Function of the callback, 4 | * will be ran on unmount. 5 | * @param cb Function to run on unmount 6 | */ 7 | export function onMountedWithCleanup(cb: () => Function | void) { 8 | let cleanup: Function | undefined = undefined 9 | onMounted(() => { 10 | const cleanup_ = cb() 11 | if (cleanup_) { 12 | cleanup = cleanup_ 13 | } 14 | }) 15 | 16 | onUnmounted(() => { 17 | if (cleanup) cleanup() 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /api/src/resources/views/PostView.ts: -------------------------------------------------------------------------------- 1 | import { BlogPost } from "#models/Blog" 2 | import { S } from "#resources/lib" 3 | import { UserViewFromId } from "../resolvers/UserResolver.js" 4 | 5 | export class BlogPostView extends S.ExtendedClass()({ 6 | ...BlogPost.omit("author"), 7 | author: S.propertySignature(UserViewFromId).pipe(S.fromKey("authorId")) 8 | }) {} 9 | 10 | // codegen:start {preset: model} 11 | // 12 | /* eslint-disable */ 13 | export namespace BlogPostView { 14 | export interface Encoded extends S.Struct.Encoded {} 15 | } 16 | /* eslint-enable */ 17 | // 18 | // codegen:end 19 | -------------------------------------------------------------------------------- /frontend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | // @ts-nocheck 3 | 4 | import { vueConfig } from "../eslint.vue.config.mjs" 5 | 6 | import path from "node:path" 7 | import { fileURLToPath } from "node:url" 8 | 9 | const __filename = fileURLToPath(import.meta.url) 10 | const __dirname = path.dirname(__filename) 11 | 12 | export default [ 13 | ...vueConfig(__dirname, false), 14 | { 15 | ignores: [".nuxt/**", ".output/**", ".storybook/**"], 16 | }, 17 | { 18 | files: ["pages/**/*.vue", "components/**/*.vue", "layouts/**/*.vue"], 19 | rules: { 20 | "vue/multi-word-component-names": "off", 21 | }, 22 | }, 23 | ] 24 | -------------------------------------------------------------------------------- /api/src/resources/Events.ts: -------------------------------------------------------------------------------- 1 | import { S } from "#resources/lib" 2 | import type { Schema } from "effect-app/Schema" 3 | 4 | export class BogusEvent extends S.ExtendedTaggedClass()("BogusEvent", { 5 | id: S.StringId.withDefault, 6 | at: S.Date.withDefault 7 | }) {} 8 | 9 | export const ClientEvents = S.Union(BogusEvent) 10 | export type ClientEvents = Schema.Type 11 | 12 | // codegen:start {preset: model} 13 | // 14 | /* eslint-disable */ 15 | export namespace BogusEvent { 16 | export interface Encoded extends S.Struct.Encoded {} 17 | } 18 | /* eslint-enable */ 19 | // 20 | // codegen:end 21 | // 22 | -------------------------------------------------------------------------------- /api/src/resources.ts: -------------------------------------------------------------------------------- 1 | import type {} from "@effect/platform/HttpClient" 2 | 3 | export { ClientEvents } from "./resources/Events.js" 4 | 5 | // codegen:start {preset: barrel, include: ./resources/*.ts, exclude: [./resources/index.ts, ./resources/lib.ts, ./resources/integrationEvents.ts, ./resources/Messages.ts, ./resources/views.ts, ./resources/Events.ts], export: { as: 'PascalCase', postfix: 'Rsc' }} 6 | export * as AccountsRsc from "./resources/Accounts.js" 7 | export * as BlogRsc from "./resources/Blog.js" 8 | export * as HelloWorldRsc from "./resources/HelloWorld.js" 9 | export * as OperationsRsc from "./resources/Operations.js" 10 | export * as UsersRsc from "./resources/Users.js" 11 | // codegen:end 12 | -------------------------------------------------------------------------------- /api/src/resources/HelloWorld.ts: -------------------------------------------------------------------------------- 1 | import { RequestContext } from "@effect-app/infra/RequestContext" 2 | import { S } from "./lib.js" 3 | import { UserView } from "./views.js" 4 | 5 | class Response extends S.Class()({ 6 | now: S.Date.withDefault, 7 | echo: S.String, 8 | context: RequestContext, 9 | currentUser: S.NullOr(UserView), 10 | randomUser: UserView 11 | }) {} 12 | 13 | export class GetHelloWorld extends S.Req()("GetHelloWorld", { 14 | echo: S.String 15 | }, { allowAnonymous: true, allowRoles: ["user"], success: Response }) {} 16 | 17 | // codegen:start {preset: meta, sourcePrefix: src/resources/} 18 | export const meta = { moduleName: "HelloWorld" } as const 19 | // codegen:end 20 | -------------------------------------------------------------------------------- /tsconfig.plugins.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "plugins": [ 4 | { 5 | "name": "ts-plugin-sort-import-suggestions", 6 | "moveUpPatterns": [ 7 | "\\.{1,2}/", 8 | "^(?:\\.\\./)+", 9 | "^#", 10 | "^@/", 11 | "effect-app", 12 | "^@effect-app/", 13 | "effect", 14 | "^@effect/" 15 | ], 16 | "moveDownPatterns": [ 17 | "^node_modules/" 18 | ], 19 | "overrides": { 20 | "effect-app": [ 21 | "Array", 22 | "Option", 23 | "Either" 24 | ] 25 | } 26 | }, 27 | { 28 | "name": "@effect/language-service" 29 | } 30 | ] 31 | } 32 | } -------------------------------------------------------------------------------- /api/src/Users.controllers.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "#lib/routing" 2 | import { UsersRsc } from "#resources" 3 | import type { UserView } from "#resources/views" 4 | import { Q, UserRepo } from "#services" 5 | import { Array } from "effect" 6 | import { Effect, Order } from "effect-app" 7 | 8 | export default Router(UsersRsc)({ 9 | dependencies: [UserRepo.Default], 10 | *effect(match) { 11 | const userRepo = yield* UserRepo 12 | 13 | return match({ 14 | IndexUsers: (req) => 15 | userRepo 16 | .query(Q.where("id", "in", req.filterByIds)) 17 | .pipe(Effect.andThen((users) => ({ 18 | users: Array.sort(users, Order.mapInput(Order.string, (_: UserView) => _.displayName)) 19 | }))) 20 | }) 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Nuxt 3 Minimal Starter 2 | 3 | Look at the [nuxt 3 documentation](https://v3.nuxtjs.org) to learn more. 4 | 5 | ## Setup 6 | 7 | Make sure to install the dependencies: 8 | 9 | ```bash 10 | # pnpm 11 | pnpm install 12 | 13 | # npm 14 | npm install 15 | 16 | # pnpm 17 | pnpm install --shamefully-hoist 18 | ``` 19 | 20 | ## Development Server 21 | 22 | Start the development server on http://localhost:3000 23 | 24 | ```bash 25 | npm run dev 26 | ``` 27 | 28 | ## Production 29 | 30 | Build the application for production: 31 | 32 | ```bash 33 | npm run build 34 | ``` 35 | 36 | Locally preview production build: 37 | 38 | ```bash 39 | npm run preview 40 | ``` 41 | 42 | Checkout the [deployment documentation](https://v3.nuxtjs.org/guide/deploy/presets) for more information. 43 | -------------------------------------------------------------------------------- /api/src/services/Events.ts: -------------------------------------------------------------------------------- 1 | import type { ClientEvents } from "#resources" 2 | import { storeId } from "@effect-app/infra/Store/Memory" 3 | import { Effect, PubSub, Stream } from "effect-app" 4 | import type { NonEmptyReadonlyArray } from "effect/Array" 5 | 6 | export class Events extends Effect.Service()("Events", { 7 | accessors: true, 8 | effect: Effect.gen(function*() { 9 | const q = yield* PubSub.unbounded<{ evt: ClientEvents; namespace: string }>() 10 | const svc = { 11 | publish: (...evts: NonEmptyReadonlyArray) => 12 | storeId.pipe(Effect.andThen((namespace) => q.offerAll(evts.map((evt) => ({ evt, namespace }))))), 13 | subscribe: q.subscribe, 14 | stream: Stream.fromPubSub(q, { scoped: true }) 15 | } 16 | return svc 17 | }) 18 | }) {} 19 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "#core/*": [ 5 | "./src/core/*.js" 6 | ], 7 | "#resources": [ 8 | "./src/resources.js" 9 | ], 10 | "#models": [ 11 | "./src/models.js" 12 | ], 13 | "#resources/*": [ 14 | "./src/resources/*.js" 15 | ], 16 | "#models/*": [ 17 | "./src/models/*.js" 18 | ], 19 | "#config": [ 20 | "./src/config.js" 21 | ], 22 | "#lib/*": [ 23 | "./src/lib/.*.js" 24 | ], 25 | "#services": [ 26 | "./src/services.js" 27 | ] 28 | }, 29 | }, 30 | "extends": "../tsconfig.base.json", 31 | "references": [ 32 | { 33 | "path": "./tsconfig.src.json" 34 | }, 35 | { 36 | "path": "./tsconfig.test.json" 37 | } 38 | ], 39 | } -------------------------------------------------------------------------------- /.vscode/operators.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Pipe Operator |": { 3 | "prefix": "+p", 4 | "body": [ 5 | "[\"|>\"]($1)" 6 | ], 7 | "description": "Pipe Operator" 8 | }, 9 | // "Flow Operator": { 10 | // "prefix": "+f", 11 | // "body": [ 12 | // "[\">>\"]($1)" 13 | // ], 14 | // "description": "Flow Operator" 15 | // }, 16 | "Compose Operator": { 17 | "prefix": "+c", 18 | "body": [ 19 | "[\">>>\"]($1)" 20 | ], 21 | "description": "Compose Operator" 22 | }, 23 | "Gen Function $": { 24 | "prefix": "$gen", 25 | "body": [ 26 | "function* () {$1}" 27 | ], 28 | "description": "Generator Function with _ input" 29 | }, 30 | "Gen Yield $": { 31 | "prefix": "yyield", 32 | "body": [ 33 | "yield* $1" 34 | ], 35 | "description": "Yield generator calling $()" 36 | } 37 | } -------------------------------------------------------------------------------- /api/src/models/Blog.ts: -------------------------------------------------------------------------------- 1 | import { S } from "effect-app" 2 | import { UserFromId } from "./User.js" 3 | 4 | export const BlogPostId = S.prefixedStringId()("post", "BlogPostId") 5 | export interface BlogPostIdBrand { 6 | readonly BlogPostId: unique symbol 7 | } 8 | export type BlogPostId = S.StringId & BlogPostIdBrand & `post-${string}` 9 | 10 | export class BlogPost extends S.ExtendedClass()({ 11 | id: BlogPostId.withDefault, 12 | title: S.NonEmptyString255, 13 | body: S.NonEmptyString2k, 14 | createdAt: S.Date.withDefault, 15 | author: S.propertySignature(UserFromId).pipe(S.fromKey("authorId")) 16 | }) {} 17 | 18 | // codegen:start {preset: model} 19 | // 20 | /* eslint-disable */ 21 | export namespace BlogPost { 22 | export interface Encoded extends S.Struct.Encoded {} 23 | } 24 | /* eslint-enable */ 25 | // 26 | // codegen:end 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | *.log 4 | **/dist/ 5 | **/dist/ 6 | !**/.eslintrc*.js 7 | .build/ 8 | dist/ 9 | .cache/ 10 | **/bin/*.js 11 | **/bin/*.js.map 12 | **/bin/*.d.ts 13 | **/bin/*.d.ts.map 14 | tsconfig.tsbuildinfo 15 | .tsbuildinfo 16 | .ultra.cache.json 17 | .jest-cache 18 | .telemetry-exporter-running 19 | 20 | .env.local 21 | .env 22 | 23 | _cjs/ 24 | _mjs/ 25 | _esm/ 26 | 27 | .data/ 28 | .data.*/ 29 | 30 | 31 | # CMake 32 | cmake-build-*/ 33 | 34 | 35 | # File-based project format 36 | *.iws 37 | 38 | # IntelliJ 39 | out/ 40 | 41 | # JIRA plugin 42 | atlassian-ide-plugin.xml 43 | 44 | # Crashlytics plugin (for Android Studio and IntelliJ) 45 | com_crashlytics_export_strings.xml 46 | crashlytics.properties 47 | crashlytics-build.properties 48 | fabric.properties 49 | 50 | # End of https://www.toptal.com/developers/gitignore/api/intellij 51 | 52 | .infra/*_rev.yaml 53 | 54 | # vitest? 55 | *.timestamp-*.mjs 56 | -------------------------------------------------------------------------------- /e2e/playwright/util.ts: -------------------------------------------------------------------------------- 1 | import type { TestSelector } from "../helpers/@types/enhanced-selectors.js" 2 | import type { LocatorAble } from "./types.js" 3 | 4 | export function enhancePageWithLocateTest(page: LocatorAble) { 5 | return (sel: TestSelector) => locateTest_(page, sel) 6 | } 7 | 8 | export function locateTest(sel: TestSelector) { 9 | return (page: LocatorAble) => locateTest_(page, sel) 10 | } 11 | 12 | export function locateTest_(page: LocatorAble, sel: TestSelector) { 13 | const [testId, ...rest] = sel.split(" ") 14 | if (testId.includes(":")) { 15 | const [testId2, ...rest2] = testId.split(":") 16 | const testSelector = `[data-test='${testId2}']:${rest2.join(":")}` 17 | return page.locator( 18 | rest.length ? testSelector + ` ${rest.join(" ")}` : testSelector 19 | ) 20 | } 21 | const testSelector = `[data-test='${testId}']` 22 | return page.locator(rest.length ? testSelector + ` ${rest.join(" ")}` : testSelector) 23 | } 24 | -------------------------------------------------------------------------------- /frontend/composables/eventsource.ts: -------------------------------------------------------------------------------- 1 | import { ClientEvents } from "#resources" 2 | import ReconnectingEventSource from "reconnecting-eventsource" 3 | import { bus } from "./bus" 4 | import { onMountedWithCleanup } from "./onMountedWithCleanup" 5 | import { S } from "effect-app" 6 | 7 | const parseEvent = S.decodeUnknownSync(ClientEvents) 8 | 9 | function listener(message: MessageEvent) { 10 | const evt = parseEvent(JSON.parse(message.data)) 11 | bus.emit("serverEvents", evt) 12 | } 13 | 14 | function makeSource() { 15 | const src = new ReconnectingEventSource("/api/api/events") 16 | src.addEventListener("message", listener) 17 | return src 18 | } 19 | 20 | export function useApiEventSource() { 21 | onMountedWithCleanup(() => { 22 | const source = makeSource() 23 | 24 | return () => { 25 | console.log("$closing source") 26 | source.removeEventListener("message", listener) 27 | source.close() 28 | } 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "./test-out", 6 | "lib": [ 7 | "es5", 8 | "dom" 9 | ], 10 | "paths": { 11 | "e2e/*": [ 12 | "./*" 13 | ] 14 | }, 15 | // helps performance 16 | "disableSourceOfProjectReferenceRedirect": true, 17 | // "transformers": [ 18 | // // Transform paths in output .js files 19 | // { 20 | // "name": "ts-transform-paths" 21 | // }, 22 | // // Transform paths in output .d.ts files (Include this line if you output declarations files) 23 | // { 24 | // "name": "ts-transform-paths", 25 | // "position": "afterDeclaration" 26 | // } 27 | // ] 28 | }, 29 | "include": [ 30 | "**/*.ts" 31 | ], 32 | "exclude": [ 33 | "node_modules", 34 | "./test-out" 35 | ], 36 | "references": [ 37 | { 38 | "path": "../api" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /e2e/helpers/runtime.ts: -------------------------------------------------------------------------------- 1 | import { Option } from "effect-app" 2 | // import { UsersRsc } from "#resources" 3 | import { makeHeadersHashMap, makeRuntime } from "./shared.js" 4 | 5 | const baseUrl = process.env["BASE_URL"] ?? "http://localhost:4000" 6 | 7 | export function makeRuntimes(namespace: string) { 8 | const apiUrl = `${baseUrl}/api/api` 9 | const { runtime: anonRuntime } = makeRuntime( 10 | { 11 | apiUrl, 12 | headers: Option.some(makeHeadersHashMap(namespace)) 13 | } 14 | ) 15 | 16 | // const { runtime: managerRuntime } = makeRuntime( 17 | // { 18 | // apiUrl, 19 | // headers: Option.some(makeHeadersHashMap(namespace, "manager")) 20 | // } 21 | // ) 22 | // const { runtime: userRuntime } = makeRuntime( 23 | // { 24 | // apiUrl, 25 | // headers: Option.some(makeHeadersHashMap(namespace, "user")) 26 | // } 27 | // ) 28 | 29 | return { 30 | anonRuntime 31 | // managerRuntime, 32 | // userRuntime 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /api/src/services/UserProfile.ts: -------------------------------------------------------------------------------- 1 | import { Role } from "#models/User" 2 | import { parseJwt } from "@effect-app/infra/api/routing/schema/jwt" 3 | import { Context, S } from "effect-app" 4 | import { UserProfileId } from "effect-app/ids" 5 | 6 | export class UserProfile extends Context.assignTag()( 7 | S.Class()({ 8 | sub: UserProfileId, 9 | roles: S.Array(Role).withDefault.pipe(S.fromKey("https://nomizz.com/roles")) 10 | }) 11 | ) { 12 | } 13 | 14 | export namespace UserProfileService { 15 | export interface Id { 16 | readonly _: unique symbol 17 | } 18 | } 19 | 20 | const userProfileFromJson = S.parseJson(UserProfile) 21 | const userProfileFromJWT = parseJwt(UserProfile) 22 | export const makeUserProfileFromAuthorizationHeader = ( 23 | authorization: string | undefined 24 | ) => S.decodeUnknown(userProfileFromJWT)(authorization) 25 | export const makeUserProfileFromUserHeader = (user: string | string[] | undefined) => 26 | S.decodeUnknown(userProfileFromJson)(user) 27 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | RUN npm i -g pnpm 4 | 5 | # Install CUPS/AVAHI 6 | RUN apk update --no-cache && apk add --no-cache cups cups-filters avahi inotify-tools 7 | 8 | WORKDIR /app 9 | 10 | ENV NODE_ENV production 11 | 12 | # pnpm fetch does require only lockfile 13 | COPY patches ./patches 14 | COPY pnpm-lock.yaml .npmrc ./ 15 | RUN pnpm fetch --prod 16 | 17 | COPY package.json pnpm-workspace.yaml ./ 18 | 19 | COPY api/package.json ./api/ 20 | 21 | # As we're going to deploy, we want only the minimal production dependencies. 22 | # TODO 23 | RUN pnpm install --frozen-lockfile --prod 24 | #RUN --mount=type=cache,target=/root/.pnpm pnpm_CACHE_FOLDER=/root/.pnpm pnpm install --frozen-lockfile --prod 25 | 26 | COPY api/dist ./api/dist 27 | 28 | #COPY data ./data 29 | 30 | WORKDIR /app/api 31 | EXPOSE 3610 32 | ENV PORT=3610 33 | ENV TZ=Europe/Berlin 34 | ARG API_VERSION 35 | ENV API_VERSION=${API_VERSION:-docker_default} 36 | ENV SENTRY_RELEASE=${API_VERSION:-docker_default} 37 | 38 | CMD ["pnpm", "start"] 39 | -------------------------------------------------------------------------------- /scripts/extract.sh: -------------------------------------------------------------------------------- 1 | 2 | # for d in `find src -type d$ | grep -v node_modules | grep -v _esm` 3 | # do 4 | # d=`echo $d | cut -c 6-` 5 | # d=./$d 6 | # echo "\"${d}\": { \"import\": { \"types\": \"${d}/index.d.ts\", \"default\": \"./_esm${d#.}/index.mjs\" }, \"require\": \"${d}/index.js\" }," 7 | # done 8 | 9 | for f in `find src -type f | grep .ts$` 10 | do 11 | f=`echo $f | cut -c 6-` 12 | f=./$f 13 | f2="./dist${f#.}" 14 | f2="${f2%.ts}.js" 15 | f3="./_cjs${f2#./dist}" 16 | f3="${f3%.js}.cjs" 17 | echo "\"${f%.ts}\": { \"import\": { \"types\": \"${f2%.js}.d.ts\", \"default\": \"$f2\" }, \"require\": { \"types\": \"${f2%.js}.d.ts\", \"default\": \"${f3}\" } }," 18 | done 19 | 20 | # for f in `find src -type f | grep .tsx$ | grep -v index.ts$ | grep -v .d.ts$ | grep -v node_modules` 21 | # do 22 | # f=`echo $f | cut -c 6-` 23 | # f=./$f 24 | # f2="./_esm${f#.}" 25 | # f2="${f2%.tsx}.mjs" 26 | # echo "\"${f%.tsx}\": { \"import\": { \"types\": \"${f%.tsx}.d.ts\", \"default\": \"$f2\" }, \"require\": \"${f%.tsx}.js\" }," 27 | # done 28 | -------------------------------------------------------------------------------- /frontend/plugins/sentry.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/vue" 2 | 3 | export default defineNuxtPlugin(nuxtApp => { 4 | const config = useRuntimeConfig() 5 | Sentry.init({ 6 | app: nuxtApp.vueApp, 7 | release: config.public.feVersion, 8 | normalizeDepth: 5, // default 3 9 | enabled: config.public.env !== "local-dev", 10 | dsn: "???", 11 | 12 | // Set tracesSampleRate to 1.0 to capture 100% 13 | // of transactions for performance monitoring. 14 | // We recommend adjusting this value in production 15 | tracesSampleRate: 1.0, 16 | beforeSend(event, hint) { 17 | if ( 18 | // skip handled errors 19 | hint.originalException && 20 | typeof hint.originalException === "object" && 21 | "name" in hint.originalException && 22 | hint.originalException["name"] === "HandledError" 23 | ) { 24 | console.warn("Sentry: skipped HandledError", hint.originalException) 25 | return null 26 | } 27 | return event 28 | }, 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Effect App Sample 2 | 3 | Demo basic usage of Models, Resources, Controllers, Clients, long running tasks and some SSE. 4 | 5 | ## Setup 6 | 7 | 1. `pnpm i` from root 8 | 2. open a typescript file, and set VSCode's Typescript version to use the workspace version: 9 | - TypeScript: Select TypeScript version: Use workspace version 10 | 11 | ## Run 12 | 13 | Use the VSCode "Run Task", "Run UI". 14 | Or see below for running manually. 15 | 16 | ### API, Models, Resources 17 | 18 | a) `pnpm build -w` 19 | b) `cd api && pnpm dev` 20 | 21 | Visit: http://localhost:3610/docs 22 | The API is also proxied in the frontend on /api 23 | 24 | ### Frontend (Nuxt) 25 | 26 | - `cd frontend && pnpm dev -o` 27 | 28 | Visit: http://localhost:4000 29 | API Docs: http://localhost:4000/api/docs 30 | 31 | Notes 32 | 33 | - Make sure you don't have the old Vue/Vetur vs code plugin installed, but the new ones only: "Vue.volar", "Vue.vscode-typescript-vue-plugin" 34 | 35 | ## Framework documentation 36 | 37 | [WIP](https://github.com/effect-ts-app/docs) 38 | -------------------------------------------------------------------------------- /Dockerfile.fe: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | RUN npm i -g pnpm 4 | 5 | ENV NODE_ENV production 6 | 7 | # RUN addgroup -g 1001 -S nodejs 8 | # RUN adduser -S nextjs -u 1001 9 | 10 | WORKDIR /app 11 | 12 | # pnpm fetch does require only lockfile 13 | COPY patches ./patches 14 | COPY pnpm-lock.yaml .npmrc ./ 15 | RUN pnpm fetch --prod 16 | 17 | COPY package.json pnpm-workspace.yaml ./ 18 | 19 | COPY /frontend/package.json /app/frontend/ 20 | 21 | #COPY /frontend /app/frontend 22 | # As we're going to deploy, we want only the minimal production dependencies. 23 | # TODO 24 | RUN pnpm install --frozen-lockfile --prod 25 | #RUN --mount=type=cache,target=/root/.pnpm pnpm_CACHE_FOLDER=/root/.pnpm pnpm install --frozen-lockfile --prod 26 | 27 | COPY /frontend/.output /app/frontend/.output 28 | 29 | WORKDIR /app/frontend 30 | 31 | # USER nextjs 32 | EXPOSE 4000 33 | ENV PORT=4000 34 | ENV TZ=Europe/Berlin 35 | ARG FE_VERSION 36 | ENV FE_VERSION=${FE_VERSION:-docker_default} 37 | ENV NUXT_PUBLIC_FE_VERSION=${FE_VERSION:-docker_default} 38 | ENV SENTRY_RELEASE=${FE_VERSION:-docker_default} 39 | 40 | CMD ["pnpm", "start"] 41 | -------------------------------------------------------------------------------- /api/src/main.ts: -------------------------------------------------------------------------------- 1 | import * as DevTools from "@effect/experimental/DevTools" 2 | import { faker } from "@faker-js/faker" 3 | import { Effect, Layer } from "effect-app" 4 | import { setFaker } from "effect-app/faker" 5 | import { api } from "./api.js" 6 | import { MergedConfig } from "./config.js" 7 | import { runMain } from "./lib/basicRuntime.js" 8 | import { AppLogger } from "./lib/logger.js" 9 | import { TracingLive } from "./lib/observability.js" 10 | 11 | setFaker(faker) 12 | const logConfig = MergedConfig.pipe( 13 | Effect.andThen((cfg) => AppLogger.logInfo(`Config: ${JSON.stringify(cfg, undefined, 2)}`)) 14 | ) 15 | 16 | const program = api 17 | .pipe( 18 | Layer.provide(logConfig.pipe(Layer.scopedDiscard)), 19 | Layer.provide(process.env["DT"] ? DevTools.layer() : Layer.empty), 20 | Layer.provideMerge(TracingLive) 21 | ) 22 | 23 | // NOTE: all dependencies should have been provided, for us to be able to run the program. 24 | // if you get a type error here on the R argument, you haven't provided that dependency yet, or not at the appropriate time / location 25 | runMain(Layer.launch(program)) 26 | -------------------------------------------------------------------------------- /frontend/composables/useRouteParams.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { typedKeysOf } from "effect-app/utils" 3 | import { 4 | parseRouteParams, 5 | parseRouteParamsOption, 6 | } from "@effect-app/vue/routeParams" 7 | import { Option, type S } from "effect-app" 8 | 9 | export const useRouteParams = >>( 10 | t: NER, // enforce non empty 11 | ) => { 12 | const r = useRoute() 13 | const result = parseRouteParams({ ...r.query, ...r.params }, t) 14 | return result 15 | } 16 | 17 | export const useRouteParamsOption = >>( 18 | t: NER, // enforce non empty 19 | ) => { 20 | const r = useRoute() 21 | const result = parseRouteParamsOption({ ...r.query, ...r.params }, t) 22 | type Result = typeof result 23 | return typedKeysOf(result).reduce( 24 | (prev, cur) => { 25 | prev[cur] = Option.getOrUndefined(result[cur]) 26 | return prev 27 | }, 28 | {} as Record, 29 | ) as unknown as { 30 | [K in keyof Result]: Result[K] extends Option 31 | ? A | undefined 32 | : never 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /frontend/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin } from "#imports" 2 | import { createVuetify } from "vuetify" 3 | import * as components from "vuetify/components" 4 | import * as directives from "vuetify/directives" 5 | import { aliases, mdi } from "vuetify/iconsets/mdi-svg" 6 | 7 | import "vuetify/styles" 8 | 9 | export default defineNuxtPlugin(nuxtApp => { 10 | const vuetify = createVuetify({ 11 | theme: { 12 | defaultTheme: "dark", 13 | themes: { 14 | dark: { 15 | colors: { 16 | primary: "#EBF857", 17 | secondary: "#03A9F4", 18 | warning: "#E91E63", 19 | }, 20 | }, 21 | }, 22 | variations: { 23 | colors: ["primary", "secondary"], 24 | lighten: 1, 25 | darken: 2, 26 | }, 27 | }, 28 | components, 29 | directives, 30 | icons: { 31 | defaultSet: "mdi", 32 | aliases, 33 | sets: { 34 | mdi, 35 | }, 36 | }, 37 | display: { 38 | thresholds: { 39 | xs: 340, 40 | sm: 540, 41 | md: 800, 42 | lg: 1280, 43 | }, 44 | }, 45 | }) 46 | 47 | nuxtApp.vueApp.use(vuetify) 48 | }) 49 | -------------------------------------------------------------------------------- /api/src/HelloWorld.controllers.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "#lib/routing" 2 | import { User } from "#models/User" 3 | import { HelloWorldRsc } from "#resources" 4 | import { GetHelloWorld } from "#resources/HelloWorld" 5 | import { UserRepo } from "#services" 6 | import { getRequestContext } from "@effect-app/infra/api/setupRequest" 7 | import { generate } from "@effect-app/infra/test" 8 | import { Effect, S } from "effect-app" 9 | 10 | export default Router(HelloWorldRsc)({ 11 | dependencies: [UserRepo.Default], 12 | *effect(match) { 13 | const userRepo = yield* UserRepo 14 | 15 | return match({ 16 | *GetHelloWorld({ echo }) { 17 | const context = yield* getRequestContext 18 | const user = yield* userRepo 19 | .tryGetCurrentUser 20 | .pipe( 21 | Effect.catchTags({ 22 | "NotLoggedInError": () => Effect.succeed(null), 23 | "NotFoundError": () => Effect.succeed(null) 24 | }) 25 | ) 26 | 27 | return new GetHelloWorld.success({ 28 | context, 29 | echo, 30 | currentUser: user, 31 | randomUser: generate(S.A.make(User)).value 32 | }) 33 | } 34 | }) 35 | } 36 | }) 37 | -------------------------------------------------------------------------------- /frontend/pages/blog/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | a new Title and a new body 15 | 25 | Create new post 26 | 27 | 28 | Here's a Post List 29 | 30 | 31 | 32 | 33 | 34 | {{ post.title }} 35 | 36 | by {{ post.author.displayName }} 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /e2e/playwright.config.ts: -------------------------------------------------------------------------------- 1 | // This config file will be imported into each test 2 | import type { PlaywrightTestConfig } from "@playwright/test" 3 | 4 | const basicAuthCredentials = process.env["BASIC_AUTH_CREDENTIALS"] 5 | 6 | const config: PlaywrightTestConfig = { 7 | // globalSetup: "./global-setup", 8 | forbidOnly: !!process.env["CI"], 9 | retries: process.env["CI"] ? 2 : 0, 10 | // workers: process.env["CI"] ? 4 : 2, 11 | use: { 12 | baseURL: process.env["BASE_URL"] ?? "http://localhost:4000", 13 | extraHTTPHeaders: basicAuthCredentials 14 | ? { 15 | "authorization": `Basic ${Buffer.from(basicAuthCredentials).toString("base64")}` 16 | } 17 | : {}, 18 | // Tell all tests to load signed-in state from 'storageState.json'. 19 | storageState: "storageState.user.json", 20 | viewport: { width: 1280, height: 720 }, 21 | ignoreHTTPSErrors: true, 22 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any 23 | video: process.env["VIDEO"] ? process.env["VIDEO"] as any : "on-first-retry", 24 | screenshot: "only-on-failure" 25 | // video: process.env["CI"] ? "on-first-retry" : "retain-on-failure", 26 | } 27 | } 28 | 29 | export default config 30 | -------------------------------------------------------------------------------- /api/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.src.json", 3 | "compilerOptions": { 4 | "rootDir": "./test", 5 | "outDir": "./test/dist", 6 | "tsBuildInfoFile": "./test/dist/.tsbuildinfo", 7 | "paths": { 8 | "#api": [ 9 | "./src/api.js" 10 | ], 11 | "#core/*": [ 12 | "./src/core/*.js" 13 | ], 14 | "#resources": [ 15 | "./src/resources.js" 16 | ], 17 | "#models": [ 18 | "./src/models.js" 19 | ], 20 | "#resources/*": [ 21 | "./src/resources/*.js" 22 | ], 23 | "#models/*": [ 24 | "./src/models/*.js" 25 | ], 26 | "#config": [ 27 | "./src/config.js" 28 | ], 29 | "#lib/*": [ 30 | "./src/lib/.*.js" 31 | ], 32 | "#services": [ 33 | "./src/services.js" 34 | ] 35 | } 36 | }, 37 | "include": [ 38 | "./test/**/*.ts" 39 | ], 40 | "exclude": [ 41 | "./dist", 42 | "./test/dist", 43 | "node_modules", 44 | "build", 45 | "lib", 46 | "dist", 47 | "**/*.d.ts.map", 48 | ".git", 49 | ".data", 50 | "**/.*", 51 | "**/*.tmp" 52 | ], 53 | "references": [ 54 | { 55 | "path": "./tsconfig.src.json" 56 | }, 57 | ] 58 | } -------------------------------------------------------------------------------- /api/src/resources/Blog.ts: -------------------------------------------------------------------------------- 1 | import { BlogPost, BlogPostId } from "#models/Blog" 2 | import { InvalidStateError, NotFoundError, OptimisticConcurrencyException } from "effect-app/client" 3 | import { OperationId } from "effect-app/Operations" 4 | import { S } from "./lib.js" 5 | import { BlogPostView } from "./views.js" 6 | 7 | export class CreatePost extends S.Req()("CreatePost", BlogPost.pick("title", "body"), { 8 | allowRoles: ["user"], 9 | success: S.Struct({ id: BlogPostId }), 10 | failure: S.Union(NotFoundError, InvalidStateError, OptimisticConcurrencyException) 11 | }) {} 12 | 13 | export class FindPost extends S.Req()("FindPost", { 14 | id: BlogPostId 15 | }, { allowAnonymous: true, allowRoles: ["user"], success: S.NullOr(BlogPostView) }) {} 16 | 17 | export class GetPosts extends S.Req()("GetPosts", {}, { 18 | allowAnonymous: true, 19 | allowRoles: ["user"], 20 | success: S.Struct({ 21 | items: S.Array(BlogPostView) 22 | }) 23 | }) {} 24 | 25 | export class PublishPost extends S.Req()("PublishPost", { 26 | id: BlogPostId 27 | }, { allowRoles: ["user"], success: OperationId, failure: S.Union(NotFoundError) }) {} 28 | 29 | // codegen:start {preset: meta, sourcePrefix: src/resources/} 30 | export const meta = { moduleName: "Blog" } as const 31 | // codegen:end 32 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # Specifies which operating system image to use. 2 | FROM mcr.microsoft.com/vscode/devcontainers/base:focal 3 | 4 | WORKDIR /home/vscode 5 | 6 | # curl and ca-certificates are needed for volta installation 7 | RUN apt-get update \ 8 | && apt-get install -y \ 9 | curl \ 10 | ca-certificates \ 11 | --no-install-recommends 12 | 13 | # Changes user to vscode and the SHELL to bash 14 | USER vscode 15 | SHELL ["/bin/bash", "-c"] 16 | 17 | # since we're starting non-interactive shell, we wil need to tell bash to load .bashrc manually 18 | ENV BASH_ENV ~/.bashrc 19 | # needed by volta() function 20 | ENV VOLTA_HOME /home/vscode/.volta 21 | # make sure packages managed by volta will be in PATH 22 | ENV PATH $VOLTA_HOME/bin:$PATH 23 | 24 | # download volta 25 | RUN curl https://get.volta.sh | bash 26 | # change the working directory to the one where the project lives 27 | WORKDIR /workspaces/boilerplate 28 | 29 | # and install node and pnpm 30 | RUN volta install node@20 31 | RUN npm i -g pnpm 32 | 33 | RUN echo 34 | 35 | ENV SHELL=/bin/bash 36 | RUN curl -L "https://humanlog.io/install.sh" | sh 37 | COPY humanlog.sh /tmp/ 38 | RUN cat /tmp/humanlog.sh >> ~/.bashrc 39 | 40 | RUN echo 41 | 42 | # prevent `npm or yarn` usage in the shell now that pnpm is installed 43 | WORKDIR /home/vscode 44 | COPY usepnpm.sh /tmp/ 45 | RUN cat /tmp/usepnpm.sh >> ~/.bashrc -------------------------------------------------------------------------------- /api/test/auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // noinspection JSUnusedGlobalSymbols 5 | // Generated by unplugin-auto-import 6 | // biome-ignore lint: disable 7 | export {} 8 | declare global { 9 | const afterAll: typeof import('@effect-app/infra/vitest')['afterAll'] 10 | const afterEach: typeof import('@effect-app/infra/vitest')['afterEach'] 11 | const assert: typeof import('@effect-app/infra/vitest')['assert'] 12 | const beforeAll: typeof import('@effect-app/infra/vitest')['beforeAll'] 13 | const beforeEach: typeof import('@effect-app/infra/vitest')['beforeEach'] 14 | const chai: typeof import('vitest')['chai'] 15 | const createRandomInstance: typeof import('@effect-app/infra/vitest')['createRandomInstance'] 16 | const createRandomInstanceI: typeof import('@effect-app/infra/vitest')['createRandomInstanceI'] 17 | const describe: typeof import('@effect-app/infra/vitest')['describe'] 18 | const expect: typeof import('@effect-app/infra/vitest')['expect'] 19 | const it: typeof import('@effect-app/infra/vitest')['it'] 20 | const layer: typeof import('@effect-app/infra/vitest')['layer'] 21 | const suite: typeof import('@effect-app/infra/vitest')['suite'] 22 | const test: typeof import('@effect-app/infra/vitest')['test'] 23 | const vi: typeof import('vitest')['vi'] 24 | const vitest: typeof import('vitest')['vitest'] 25 | } 26 | -------------------------------------------------------------------------------- /vitest.workspace.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path" 2 | import { defineWorkspace, type UserWorkspaceConfig } from "vitest/config" 3 | 4 | // Remaining issues: 5 | // - Random failures (browser): https://github.com/vitest-dev/vitest/issues/4497 6 | // - Alias resolution (browser, has workaround): https://github.com/vitest-dev/vitest/issues/4744 7 | // - Workspace optimization: https://github.com/vitest-dev/vitest/issues/4746 8 | 9 | // TODO: Once https://github.com/vitest-dev/vitest/issues/4497 and https://github.com/vitest-dev/vitest/issues/4746 10 | // are resolved, we can create specialized workspace groups in separate workspace files to better control test groups 11 | // with different dependencies (e.g. playwright browser) in CI. 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 14 | const project = ( 15 | config: UserWorkspaceConfig["test"] & { name: `${string}|${string}` }, 16 | root = config.root ?? path.join(__dirname, `packages/${config.name.split("|").at(0)}`) 17 | ) => ({ 18 | // extends: "vitest.shared.ts", 19 | test: { root, ...config } 20 | }) 21 | 22 | export default defineWorkspace([ 23 | // Add specialized configuration for some packages. 24 | // project({ name: "effect|browser", environment: "happy-dom" }), 25 | // project({ name: "schema|browser", environment: "happy-dom" }), 26 | // Add the default configuration for all packages. 27 | "*" 28 | ]) 29 | -------------------------------------------------------------------------------- /api/tsconfig.src.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "lib": [ 5 | "esnext" 6 | ], 7 | "tsBuildInfoFile": "./dist/.tsbuildinfo", 8 | "esModuleInterop": true, 9 | "baseUrl": "./", 10 | "rootDir": "./src", 11 | // keep in here, cause madge can't detect it from extended tsconfig 12 | "moduleResolution": "Node16", 13 | "paths": { 14 | "#core/*": [ 15 | "./src/core/*.js" 16 | ], 17 | "#resources": [ 18 | "./src/resources.js" 19 | ], 20 | "#models": [ 21 | "./src/models.js" 22 | ], 23 | "#resources/*": [ 24 | "./src/resources/*.js" 25 | ], 26 | "#models/*": [ 27 | "./src/models/*.js" 28 | ], 29 | "#config": [ 30 | "./src/config.js" 31 | ], 32 | "#lib/*": [ 33 | "./src/lib/.*.js" 34 | ], 35 | "#services": [ 36 | "./src/services.js" 37 | ] 38 | }, 39 | "types": [ 40 | "../types/modules", 41 | "vite/types/importMeta.d.ts" 42 | ], 43 | "outDir": "./dist", 44 | }, 45 | "include": [ 46 | "./src/**/*.ts" 47 | ], 48 | "exclude": [ 49 | "./dist", 50 | "*.test.ts", 51 | "node_modules", 52 | "build", 53 | "lib", 54 | "dist", 55 | "**/*.d.ts.map", 56 | ".git", 57 | ".data", 58 | "**/.*", 59 | "**/*.tmp" 60 | ] 61 | } -------------------------------------------------------------------------------- /frontend/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | 22 | 23 | 24 | Home 25 | | 26 | Blog 27 | 28 | 29 | {{ router.currentRoute.value.name }} 30 | 31 | 32 | 33 | {{ latest.displayName }} 34 | Logout 35 | 36 | 37 | Login 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /frontend/server/middleware/basicAuth.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage } from "http" 2 | 3 | export default defineEventHandler(event => { 4 | const config = useRuntimeConfig() 5 | const base64Credentials = event.req.headers?.authorization?.split(" ")?.[1] 6 | 7 | const { originalUrl } = event.req as IncomingMessageExtended 8 | 9 | let allow = 10 | !config.basicAuthCredentials || 11 | config.basicAuthCredentials === "false" || 12 | (originalUrl && 13 | (originalUrl.startsWith("/api/") || 14 | originalUrl.startsWith("/.") || 15 | originalUrl === "/api" || 16 | originalUrl === "/manifest.json" || 17 | originalUrl.startsWith("/_nuxt/") || 18 | originalUrl.startsWith("/icons/"))) 19 | 20 | if (!allow && base64Credentials) { 21 | const credentials = Buffer.from(base64Credentials, "base64").toString( 22 | "ascii", 23 | ) 24 | 25 | const [username, password] = credentials.split(":") 26 | const [requiredUserName, requiredPassword] = 27 | config.basicAuthCredentials.split(":") 28 | 29 | allow = username === requiredUserName && password === requiredPassword 30 | } 31 | 32 | if (!allow) { 33 | event.res.statusCode = 401 34 | event.res.setHeader("WWW-Authenticate", 'Basic realm="boilerplate"') 35 | event.res.end("Unauthorized") 36 | } 37 | }) 38 | 39 | interface IncomingMessageExtended extends IncomingMessage { 40 | originalUrl?: string 41 | } 42 | -------------------------------------------------------------------------------- /frontend/components/TextField.vue: -------------------------------------------------------------------------------- 1 | 2 | convertOut(value, updateValue, field.type)" 12 | > 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 42 | -------------------------------------------------------------------------------- /api/src/resources/lib/req.ts: -------------------------------------------------------------------------------- 1 | import type { Role } from "#models/User" 2 | import { NotLoggedInError, UnauthorizedError } from "@effect-app/infra/errors" 3 | import { Duration, Layer, Request as EffectRequest } from "effect-app" 4 | import { ApiClientFactory } from "effect-app/client/apiClientFactory" 5 | import { makeRpcClient, type RPCContextMap } from "effect-app/client/req" 6 | 7 | type CTXMap = { 8 | // we put `never`, because we can't access this service here in the client, and we also don't need to 9 | // TODO: a base map for client, that the server extends 10 | allowAnonymous: RPCContextMap.Inverted<"userProfile", never, typeof NotLoggedInError> 11 | // TODO: not boolean but `string[]` 12 | requireRoles: RPCContextMap.Custom<"", void, typeof UnauthorizedError, Array> 13 | } 14 | 15 | export type RequestConfig = { 16 | /** Disable authentication requirement */ 17 | allowAnonymous?: true 18 | /** Control the roles that are required to access the resource */ 19 | allowRoles?: readonly Role[] 20 | } 21 | 22 | export const { TaggedRequest: Req } = makeRpcClient({ 23 | allowAnonymous: NotLoggedInError, 24 | requireRoles: UnauthorizedError 25 | }) 26 | 27 | export const RequestCacheLayers = Layer.mergeAll( 28 | Layer.setRequestCache( 29 | EffectRequest.makeCache({ capacity: 500, timeToLive: Duration.hours(8) }) 30 | ), 31 | Layer.setRequestCaching(true), 32 | Layer.setRequestBatching(true) 33 | ) 34 | export const clientFor = ApiClientFactory.makeFor(RequestCacheLayers) 35 | -------------------------------------------------------------------------------- /e2e/global-setup.ts.bak: -------------------------------------------------------------------------------- 1 | import type { Browser, FullConfig } from "@playwright/test" 2 | import { chromium } from "@playwright/test" 3 | 4 | async function globalSetup(_config: FullConfig) { 5 | if (process.env["SKIP_LOGIN"]) { 6 | return 7 | } 8 | const baseUrl = process.env["BASE_URL"] ?? "http://localhost:4000" 9 | const browser = await chromium.launch() 10 | const l = login(baseUrl, browser) 11 | try { 12 | await l("some user", "some password", "storageState.manager.json") 13 | } finally { 14 | await browser.close() 15 | } 16 | } 17 | 18 | function login(baseUrl: string, browser: Browser) { 19 | return async (userName: string, password: string, storageStatePath: string) => { 20 | const page = await browser.newPage() 21 | const basicAuthCredentials = process.env["BASIC_AUTH_CREDENTIALS"] 22 | if (basicAuthCredentials) { 23 | await page.context().setExtraHTTPHeaders( 24 | { 25 | "authorization": `Basic ${Buffer.from(basicAuthCredentials).toString("base64")}` 26 | } 27 | ) 28 | } 29 | await page.goto(baseUrl + "/login") 30 | await page.fill(`input[placeholder="Email eingeben"]`, userName) 31 | await page.fill(`input[placeholder="Passwort eingeben"]`, password) 32 | await page.click(`text="Anmelden"`) 33 | await page.waitForURL(u => { 34 | const url = u.toString() 35 | return url === baseUrl + "/" 36 | }) 37 | await page.context().storageState({ path: storageStatePath }) 38 | } 39 | } 40 | 41 | export default globalSetup 42 | -------------------------------------------------------------------------------- /frontend/composables/client.ts: -------------------------------------------------------------------------------- 1 | import { makeClient } from "@effect-app/vue/makeClient" 2 | import { useToast } from "vue-toastification" 3 | import { useIntl } from "./intl" 4 | import { runtime, type RT } from "~/plugins/runtime" 5 | import type { Effect } from "effect-app" 6 | import { clientFor as clientFor_ } from "#resources/lib" 7 | import type { Requests } from "effect-app/client/clientFor" 8 | import { OperationsClient } from "#resources/Operations" 9 | 10 | export { useToast } from "vue-toastification" 11 | 12 | export { Result, makeContext } from "@effect-app/vue" 13 | export { 14 | pauseWhileProcessing, 15 | useIntervalPauseWhileProcessing, 16 | composeQueries, 17 | SuppressErrors, 18 | mapHandler, 19 | } from "@effect-app/vue" 20 | 21 | const rt = computed(() => runtime.value?.runtime) 22 | 23 | export const run = ( 24 | effect: Effect.Effect, 25 | options?: 26 | | { 27 | readonly signal?: AbortSignal 28 | } 29 | | undefined, 30 | ) => runtime.value!.runPromise(effect, options) 31 | 32 | export const runSync = (effect: Effect.Effect) => 33 | runtime.value!.runSync(effect) 34 | 35 | export const clientFor = (m: M) => runSync(clientFor_(m)) 36 | export const useOperationsClient = () => runSync(OperationsClient) 37 | 38 | export const { 39 | buildFormFromSchema, 40 | makeUseAndHandleMutation, 41 | useAndHandleMutation, 42 | useSafeMutation, 43 | useSafeMutationWithState, 44 | useSafeQuery, 45 | } = makeClient(useIntl, useToast, rt) 46 | -------------------------------------------------------------------------------- /frontend/composables/useModelWrapper.ts: -------------------------------------------------------------------------------- 1 | import { type WritableComputedRef, computed } from "vue" 2 | 3 | export const useModelWrapper = useValueWrapper("modelValue") 4 | 5 | export function useValueWrapper(name: Name) { 6 | function use( 7 | props: { [P in Name]: T }, 8 | emit: { (event: `update:${Name}`, value: T): void }, 9 | ): WritableComputedRef 10 | function use( 11 | props: { [P in Name]?: T }, 12 | emit: { (event: `update:${Name}`, value: T | undefined): void }, 13 | ): WritableComputedRef 14 | function use( 15 | props: { [P in Name]?: T }, 16 | emit: { (event: `update:${Name}`, value: T | undefined): void }, 17 | ) { 18 | return useValueWrapper_(props, emit, name) 19 | } 20 | 21 | return use 22 | } 23 | 24 | export function useValueWrapper_( 25 | props: { [P in Name]: T }, 26 | emit: { (event: `update:${Name}`, value: T): void }, 27 | name: Name, 28 | ): WritableComputedRef 29 | export function useValueWrapper_( 30 | props: { [P in Name]?: T }, 31 | emit: { (event: `update:${Name}`, value: T | undefined): void }, 32 | name: Name, 33 | ): WritableComputedRef 34 | export function useValueWrapper_( 35 | props: { [P in Name]?: T }, 36 | emit: { (event: `update:${Name}`, value: T | undefined): void }, 37 | name: Name, 38 | ) { 39 | return computed({ 40 | get: () => props[name], 41 | set: value => emit(`update:${name}`, value), 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /scripts/clean-dist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | for d in `find dist -type d | grep -v dist$` 4 | do 5 | src_d="src/${d#dist/}" 6 | if [ ! -d "$src_d" ]; then 7 | echo "Removing $d" 8 | rm -rf $d 9 | fi 10 | done 11 | 12 | for f in `find dist -type f | grep \\\.mjs$` 13 | do 14 | src_f="src/${f#dist/}" 15 | src_f="${src_f%.mjs}.mts" 16 | raw="${f%.mjs}" 17 | if [ ! -f "$src_f" ]; then 18 | echo "Removing $raw.mjs" 19 | rm -f $raw.mjs $raw.mjs.map $raw.d.mts $raw.d.mts.map 20 | fi 21 | done 22 | 23 | 24 | for f in `find dist -type f | grep \\\.js$` 25 | do 26 | src_f="src/${f#dist/}" 27 | src_f="${src_f%.js}.ts" 28 | raw="${f%.js}" 29 | if [ ! -f "$src_f" ]; then 30 | echo "Removing $raw.js" 31 | rm -f $raw.js $raw.js.map $raw.d.ts $raw.d.ts.map 32 | fi 33 | done 34 | 35 | 36 | for d in `find test/dist -type d | grep -v dist$` 37 | do 38 | src_d="test/${d#test/dist/}" 39 | if [ ! -d "$src_d" ]; then 40 | echo "Removing $d" 41 | rm -rf $d 42 | fi 43 | done 44 | 45 | for f in `find test/dist -type f | grep \\\.mjs$` 46 | do 47 | src_f="test/${f#test/dist/}" 48 | src_f="${src_f%.mjs}.mts" 49 | raw="${f%.mjs}" 50 | if [ ! -f "$src_f" ]; then 51 | echo "Removing $raw.mjs" 52 | rm -f $raw.mjs $raw.mjs.map $raw.d.mts $raw.d.mts.map 53 | fi 54 | done 55 | 56 | 57 | for f in `find test/dist -type f | grep \\\.js$` 58 | do 59 | src_f="test/${f#test/dist/}" 60 | src_f="${src_f%.js}.ts" 61 | raw="${f%.js}" 62 | if [ ! -f "$src_f" ]; then 63 | echo "Removing $raw.js" 64 | rm -f $raw.js $raw.js.map $raw.d.ts $raw.d.ts.map 65 | fi 66 | done -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "API debug", 5 | "request": "launch", 6 | "cwd": "${workspaceFolder}/api", 7 | "runtimeArgs": [ 8 | "debug" 9 | ], 10 | "runtimeExecutable": "pnpm", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "sourceMaps": true, 15 | "resolveSourceMapLocations": [ 16 | "${workspaceFolder}/api/src/**/*", 17 | "${workspaceFolder}/api/dist/**/*", 18 | "${workspaceFolder}/node_modules/**/*" 19 | ], 20 | "type": "node" 21 | }, 22 | // { 23 | // "name": "UI debug", 24 | // "request": "launch", 25 | // "cwd": "${workspaceFolder}/frontend", 26 | // "runtimeArgs": [ 27 | // "dev:debug" 28 | // ], 29 | // "runtimeExecutable": "pnpm", 30 | // "skipFiles": [ 31 | // "/**" 32 | // ], 33 | // "type": "node", 34 | // }, 35 | { 36 | "name": "API Debug Attach", 37 | "port": 9229, 38 | "cwd": "${workspaceFolder}/api", 39 | "request": "attach", 40 | "skipFiles": [ 41 | "/**" 42 | ], 43 | "type": "node", 44 | "sourceMaps": true 45 | }, 46 | { 47 | "name": "UI Debug Attach", 48 | "port": 9230, 49 | "cwd": "${workspaceFolder}/frontend", 50 | "request": "attach", 51 | "skipFiles": [ 52 | "/**" 53 | ], 54 | "type": "node", 55 | "webRoot": "${workspaceFolder}/frontend", 56 | "sourceMaps": true 57 | }, 58 | ] 59 | } -------------------------------------------------------------------------------- /api/src/services/DBContext/BlogPostRepo.ts: -------------------------------------------------------------------------------- 1 | import { RepoDefault } from "#lib/layers" 2 | import { BlogPost } from "#models/Blog" 3 | import { UserFromIdResolver } from "#models/User" 4 | import { Model } from "@effect-app/infra" 5 | import { Effect } from "effect" 6 | import { Context } from "effect-app" 7 | import { NonEmptyString255, NonEmptyString2k } from "effect-app/Schema" 8 | import { UserRepo } from "./UserRepo.js" 9 | 10 | export type BlogPostSeed = "sample" | "" 11 | 12 | export class BlogPostRepo extends Effect.Service()("BlogPostRepo", { 13 | dependencies: [RepoDefault, UserRepo.Default, UserRepo.UserFromIdLayer], 14 | effect: Effect.gen(function*() { 15 | const seed = "sample" 16 | const userRepo = yield* UserRepo 17 | const resolver = yield* UserFromIdResolver 18 | 19 | const makeInitial = yield* Effect.cached( 20 | seed === "sample" 21 | ? userRepo 22 | .all 23 | .pipe( 24 | Effect.andThen((users) => 25 | users 26 | .flatMap((_) => [_, _]) 27 | .map((user, i) => 28 | new BlogPost({ 29 | title: NonEmptyString255("Test post " + i), 30 | body: NonEmptyString2k("imma test body"), 31 | author: user 32 | }, true) 33 | ) 34 | ) 35 | ) 36 | : Effect.succeed([]) 37 | ) 38 | 39 | return yield* Model.makeRepo( 40 | "BlogPost", 41 | BlogPost, 42 | { 43 | makeInitial, 44 | schemaContext: Context.make(UserFromIdResolver, resolver) 45 | } 46 | ) 47 | }) 48 | }) { 49 | } 50 | -------------------------------------------------------------------------------- /e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@effect-app-boilerplate/e2e", 3 | "version": "1.0.0", 4 | "license": "NONE", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "test": "playwright test", 9 | "test:watch": "playwright-watch test", 10 | "build": "tsc --build", 11 | "lint": "NODE_OPTIONS=--max-old-space-size=8192 ESLINT_TS=1 eslint .", 12 | "lint:watch": "ESLINT_TS=1 esw -w --changed --clear --ext ts,tsx .", 13 | "autofix": "pnpm lint --fix", 14 | "up": "pnpm run update && pnpm exec-update", 15 | "update": "pnpm ncu -u", 16 | "exec-update": "pnpm i", 17 | "clean": "rm -rf test-out", 18 | "ncu": "ncu", 19 | "watch": "pnpm build --watch" 20 | }, 21 | "devDependencies": { 22 | "@playwright/test": "~1.52.0", 23 | "@types/node": "~22.15.16", 24 | "date-fns": "^4.1.0", 25 | "eslint-config-prettier": "^10.1.3", 26 | "eslint-import-resolver-typescript": "^4.3.4", 27 | "eslint-import-resolver-webpack": "^0.13.10", 28 | "eslint-plugin-import": "^2.31.0", 29 | "eslint-plugin-prettier": "^5.4.0", 30 | "eslint-plugin-simple-import-sort": "^12.1.1", 31 | "eslint-plugin-sort-destructure-keys": "^2.0.0", 32 | "eslint-plugin-unused-imports": "^4.1.4", 33 | "npm-check-updates": "^18.0.1", 34 | "playwright-core": "^1.52.0", 35 | "playwright-watch": "^1.3.23", 36 | "prettier": "^3.5.3", 37 | "typescript": "~5.8.3" 38 | }, 39 | "dependencies": { 40 | "@effect-app-boilerplate/api": "workspace:*", 41 | "@effect/platform-node": "0.77.10", 42 | "effect-app": "^2.40.1", 43 | "@effect/platform": "^0.80.20", 44 | "effect": "^3.14.20", 45 | "cross-fetch": "^4.1.0" 46 | } 47 | } -------------------------------------------------------------------------------- /api/src/config/base.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv" 2 | import { Config as C, Redacted, S } from "effect-app" 3 | 4 | const envFile = "./.env.local" 5 | 6 | const { error } = dotenv.config({ path: envFile }) 7 | if (error) { 8 | console.log("did not load .env.local") 9 | } else { 10 | console.log("loading env from: " + envFile) 11 | } 12 | 13 | const FROM = { 14 | name: S.NonEmptyString255("@effect-app/boilerplate"), 15 | email: S.Email("noreply@example.com") 16 | } 17 | 18 | const serviceName = "effect-app-boilerplate" 19 | 20 | export const envConfig = C.string("env").pipe(C.withDefault("local-dev")) 21 | 22 | export const SendgridConfig = C.all({ 23 | realMail: C.boolean("realMail").pipe(C.withDefault(false)), 24 | apiKey: C.redacted("sendgridApiKey").pipe(C.withDefault( 25 | Redacted.make("") 26 | )), 27 | defaultFrom: C.succeed(FROM), 28 | subjectPrefix: envConfig.pipe(C.map((env) => env === "prod" ? "" : `[${serviceName}] [${env}] `)) 29 | }) 30 | 31 | export const BaseConfig = C.all({ 32 | apiVersion: C.string("apiVersion").pipe(C.withDefault("local-dev")), 33 | serviceName: C.succeed(serviceName), 34 | env: envConfig, 35 | sendgrid: SendgridConfig, 36 | sentry: C.all({ 37 | dsn: C 38 | .redacted("dsn") 39 | .pipe( 40 | C.nested("sentry"), 41 | C.withDefault( 42 | Redacted.make( 43 | "???" 44 | ) 45 | ) 46 | ) 47 | }) 48 | // log: C.string("LOG"). 49 | }) 50 | type ConfigA = Cfg extends C.Config ? A : never 51 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 52 | export interface BaseConfig extends ConfigA {} 53 | 54 | export const SB_PREFIX = "Endpoint=sb://" 55 | -------------------------------------------------------------------------------- /frontend/pages/blog/[id].vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 41 | 42 | The latest bogus event is: {{ bogusOutput.id }} at {{ bogusOutput.at }} 43 | 44 | 45 | 46 | 47 | 48 | Publish to all blog sites 49 | {{ publishing.loading ? `(${progress})` : "" }} 50 | 51 | Title: {{ latest.title }} 52 | Body: {{ latest.body }} 53 | by {{ latest.author.displayName }} 54 | 55 | 56 | -------------------------------------------------------------------------------- /e2e/helpers/fillInputs.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator, type Page } from "@playwright/test" 2 | 3 | export function fillInputs(values: Record) { 4 | return async (page: Page) => { 5 | for (const [key, value] of Object.entries(values)) { 6 | const locator = page.locator(inputSelector(key)) 7 | if (typeof value === "boolean") { 8 | await locator.setChecked(value) 9 | } else { 10 | await locator.fill(value) 11 | } 12 | } 13 | } 14 | } 15 | 16 | export function validateInputs(values: Record) { 17 | return async (page: Page) => { 18 | for (const [key, value] of Object.entries(values)) { 19 | const locator = page.locator(inputSelector(key)) 20 | if (typeof value === "boolean") { 21 | await (value 22 | ? expect(locator).toBeChecked() 23 | : expect(locator).not.toBeChecked()) 24 | } else { 25 | // On initial render, the form may have empty values, so we first wait for those to disappear 26 | // TODO: This seems only a workaround. Why doesn't the form have the right values from the start? 27 | // Or is this to be expected, and should we build in some other signal to wait for? 28 | if (value !== "") { 29 | await expect(locator).not.toHaveValue("") 30 | } 31 | await expect(locator).toHaveValue(value) 32 | } 33 | } 34 | } 35 | } 36 | 37 | function inputSelector(key: string) { 38 | return `input[name='${key}'], textarea[name='${key}']` 39 | } 40 | 41 | export async function submit(button: Locator, waitForStates = false) { 42 | if (!waitForStates) { 43 | await button.click() 44 | } else { 45 | await Promise.all([expect(button).toBeDisabled(), button.click()]) 46 | await expect(button).toBeEnabled() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /vite.config.base.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import fs from "fs" 3 | import path from "path" 4 | import type { UserConfig } from "vite" 5 | import tsconfigPaths from "vite-tsconfig-paths" 6 | 7 | const pj = require("./package.json") 8 | 9 | const basePj = pj.name.replace("/root", "") 10 | 11 | export default function makeConfig( 12 | dirName?: string, 13 | useDist = process.env.TEST_USE_DIST === "true", 14 | useTransform = false 15 | ): UserConfig { 16 | const alias = (name: string) => ({ 17 | [basePj + "/" + name]: path.join(__dirname, `/${name}/` + (useDist || useTransform ? "dist" : "src")) 18 | }) 19 | const projects = ["api"] 20 | const d = dirName ? dirName + "/" : "" 21 | return { 22 | plugins: useDist 23 | ? [] 24 | : useTransform 25 | ? [ 26 | require("@effect-app/compiler/vitePlugin2").effectPlugin({ 27 | tsconfig: dirName ? d + "tsconfig.json" : undefined 28 | }) 29 | ] 30 | : [tsconfigPaths({ projects: projects.map((_) => path.join(__dirname, `/${_}`)) })], 31 | test: { 32 | include: useDist ? ["./dist/**/*.test.js"] : ["./src/**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], 33 | exclude: ["./test/**/*"], 34 | reporters: "verbose", 35 | globals: true 36 | }, 37 | resolve: dirName 38 | ? { 39 | alias: { 40 | ...projects.reduce( 41 | (acc, cur) => ({ ...acc, ...alias(cur) }), 42 | {} 43 | ), 44 | [JSON.parse(fs.readFileSync(dirName + "/package.json", "utf-8")).name]: path.join( 45 | dirName, 46 | useDist ? "/dist" : "/src" 47 | ), 48 | "@opentelemetry/resources": path.resolve( 49 | __dirname, 50 | "node_modules/@opentelemetry/resources/build/src/index.js" 51 | ) 52 | } 53 | } 54 | : undefined 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /e2e/helpers/shared.ts: -------------------------------------------------------------------------------- 1 | import { initializeSync } from "@effect-app/vue/runtime" 2 | import { FetchHttpClient } from "@effect/platform" 3 | import { HashMap, Layer } from "effect" 4 | import { ApiClientFactory, type ApiConfig } from "effect-app/client" 5 | import { typedKeysOf } from "effect-app/utils" 6 | import { readFileSync } from "fs" 7 | 8 | export function makeRuntime(config: ApiConfig) { 9 | const layers = ApiClientFactory.layer(config).pipe(Layer.provide(FetchHttpClient.layer)) 10 | const runtime = initializeSync(layers) 11 | 12 | return runtime 13 | } 14 | 15 | export function makeHeaders(namespace: string, role?: "manager") { 16 | const basicAuthCredentials = process.env["BASIC_AUTH_CREDENTIALS"] 17 | let cookie: string | undefined = undefined 18 | if (role) { 19 | const f = readFileSync("./storageState." + role + ".json", "utf-8") 20 | const p = JSON.parse(f) as { cookies: { name: string; value: string }[] } 21 | const cookies = p.cookies 22 | cookie = cookies.map((_) => `${_.name}=${_.value}`).join(";") 23 | } 24 | return > { 25 | ...(basicAuthCredentials 26 | ? { "authorization": `Basic ${Buffer.from(basicAuthCredentials).toString("base64")}` } 27 | : undefined), 28 | ...(cookie ? { "Cookie": cookie } : undefined), 29 | "x-store-id": namespace 30 | } 31 | } 32 | 33 | export function makeHeadersHashMap(namespace: string, role?: "manager") { 34 | const headers = makeHeaders(namespace, role) 35 | const keys = typedKeysOf(headers) 36 | return HashMap.make(...keys.map((_) => [_, headers[_]!] as const)) 37 | } 38 | 39 | type Env = ApiClientFactory 40 | export type SupportedEnv = Env // Effect.DefaultEnv | 41 | 42 | export function toBase64(b: string) { 43 | if (typeof window != "undefined" && window.btoa) { 44 | return window.btoa(b) 45 | } 46 | return Buffer.from(b, "utf-8").toString("base64") 47 | } 48 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "explorer.sortOrderLexicographicOptions": "upper", 3 | "typescript.preferences.includePackageJsonAutoImports": "on", 4 | "typescript.preferences.autoImportFileExcludePatterns": [ 5 | "nuxt/dist", 6 | ".nuxt" 7 | ], 8 | "typescript.tsdk": "node_modules/typescript/lib", 9 | "eslint.workingDirectories": [ 10 | { 11 | "pattern": "." 12 | }, 13 | { 14 | "pattern": "frontend" 15 | }, 16 | { 17 | "pattern": "api" 18 | }, 19 | { 20 | "pattern": "e2e" 21 | } 22 | ], 23 | "eslint.execArgv": [ 24 | "--max-old-space-size=8192" 25 | ], 26 | "editor.formatOnSave": true, 27 | "eslint.format.enable": true, 28 | "[vue]": { 29 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 30 | "editor.codeActionsOnSave": [ 31 | "source.addMissingImports" 32 | ] 33 | }, 34 | "[javascript]": { 35 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 36 | "editor.codeActionsOnSave": [ 37 | "source.addMissingImports" 38 | ] 39 | }, 40 | "[javascriptreact]": { 41 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 42 | "editor.codeActionsOnSave": [ 43 | "source.addMissingImports" 44 | ] 45 | }, 46 | "[json]": { 47 | "editor.defaultFormatter": "vscode.json-language-features" 48 | }, 49 | "[typescript]": { 50 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 51 | "editor.codeActionsOnSave": [ 52 | "source.addMissingImports" 53 | ] 54 | }, 55 | "[typescriptreact]": { 56 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 57 | "editor.codeActionsOnSave": [ 58 | "source.addMissingImports" 59 | ] 60 | }, 61 | "csv-preview.separator": ";", 62 | "cSpell.words": [ 63 | "codegen", 64 | "composables", 65 | "pipeable", 66 | "tsplus" 67 | ], 68 | "githubPullRequests.ignoredPullRequestBranches": [ 69 | "main" 70 | ] 71 | } -------------------------------------------------------------------------------- /eslint.vue.config.mjs: -------------------------------------------------------------------------------- 1 | import formatjs from "eslint-plugin-formatjs" 2 | import pluginVue from "eslint-plugin-vue" 3 | import { defineConfigWithVueTs, vueTsConfigs} from '@vue/eslint-config-typescript'; 4 | import vuePrettierConfig from "@vue/eslint-config-prettier" 5 | 6 | import tseslint from 'typescript-eslint'; 7 | 8 | import { baseConfig } from "./eslint.base.config.mjs" 9 | 10 | /** 11 | * @param {string} dirName 12 | * @param {boolean} [forceTS=false] 13 | * @returns {import("eslint").Linter.FlatConfig[]} 14 | */ 15 | export function vueConfig(dirName, forceTS = false) { 16 | const enableTS = !!dirName && (forceTS || process.env["ESLINT_TS"]) 17 | 18 | return [ 19 | ...baseConfig(dirName, forceTS), 20 | // ...ts.configs.recommended, 21 | // this should set the vue parser as the parser plus some recommended rules 22 | ...pluginVue.configs["flat/recommended"], 23 | ...defineConfigWithVueTs(vueTsConfigs.base), 24 | vuePrettierConfig, 25 | { 26 | name: "vue", 27 | files: ["*.vue", "**/*.vue"], 28 | languageOptions: { 29 | parserOptions: { 30 | // set a custom parser to parse 58 | -------------------------------------------------------------------------------- /frontend/pages/index.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 50 | 51 | Hi world! 52 | 53 | 54 | 55 | 63 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /patches/eslint-plugin-codegen@0.17.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/index.js b/dist/index.js 2 | index 69126edc70f6abdf3ba0306db22059fc812bfa89..6ea6404fedc663e7b74357503e38c02d141c8ee7 100644 3 | --- a/dist/index.js 4 | +++ b/dist/index.js 5 | @@ -55,7 +55,7 @@ exports.processors = { 6 | }; 7 | const codegen = { 8 | // @ts-expect-error types are wrong? 9 | - meta: { fixable: true }, 10 | + meta: { fixable: true, schema: false }, 11 | create(context) { 12 | const validate = () => { 13 | const sourceCode = context 14 | @@ -115,7 +115,7 @@ const codegen = { 15 | return; 16 | } 17 | const opts = maybeOptions.right || {}; 18 | - const presets = Object.assign(Object.assign({}, presetsModule), (_a = context.options[0]) === null || _a === void 0 ? void 0 : _a.presets); 19 | + const presets = Object.assign(Object.assign({}, presetsModule), (_a = context.options[0]) === null || _a === void 0 ? void 0 : typeof _a.presets === "string" ? require(_a.presets) : _a.presets); 20 | const preset = typeof (opts === null || opts === void 0 ? void 0 : opts.preset) === 'string' && presets[opts.preset]; 21 | if (typeof preset !== 'function') { 22 | context.report({ 23 | @@ -129,7 +129,7 @@ const codegen = { 24 | const normalise = (val) => val.trim().replace(/\r?\n/g, os.EOL); 25 | const result = (0, Either_1.tryCatch)(() => { 26 | const meta = { filename: context.getFilename(), existingContent }; 27 | - return preset({ meta, options: opts }); 28 | + return preset({ meta, options: opts }, context); 29 | }, err => `${err}`); 30 | if (result._tag === 'Left') { 31 | context.report({ message: result.left, loc: startMarkerLoc }); 32 | diff --git a/dist/presets/index.d.ts b/dist/presets/index.d.ts 33 | index a919405f6aeb9f3d3887d69d10350b46b790cf8e..3122de22e92cdf2d95d8bd881d054b35eb223f0c 100644 34 | --- a/dist/presets/index.d.ts 35 | +++ b/dist/presets/index.d.ts 36 | @@ -4,7 +4,7 @@ export declare type Preset = (params: { 37 | existingContent: string; 38 | }; 39 | options: Options; 40 | -}) => string; 41 | +}, context: any) => string; 42 | export * from './barrel'; 43 | export * from './custom'; 44 | export * from './empty'; 45 | -------------------------------------------------------------------------------- /.vscode/model.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "ModelCodegen": { 3 | "prefix": "genmod", 4 | "body": [ 5 | "// codegen:start {preset: model}" 6 | ] 7 | }, 8 | "ResourceCodegen": { 9 | "prefix": "genrsc", 10 | "body": [ 11 | "// codegen:start {preset: meta, sourcePrefix: src/resources/}" 12 | ] 13 | }, 14 | "ModelComplete": { 15 | "prefix": "moc", 16 | "body": [ 17 | "export class $1 extends S.ExtendedClass<$1, $1.Encoded>()({", 18 | "$2", 19 | "}) {}" 20 | ], 21 | "description": "Defines a Model signature" 22 | }, 23 | "Model": { 24 | "prefix": "mo", 25 | "body": [ 26 | "export class $1 extends S.Class<$1>()({$2}) {}", 27 | "" 28 | ], 29 | "description": "Defines a Model signature" 30 | }, 31 | "UnionOpaque": { 32 | "prefix": "un", 33 | "body": [ 34 | "const $1__ = union({ $2 })", 35 | "const $1_ = enhanceClassUnion(OpaqueSchema<$1, $1.Encoded>()($1__))", 36 | "export type $1 = To & UnionBrand", 37 | "export interface $1Schema extends Identity {}", 38 | "export const $1: $1Schema = $1_", 39 | "export namespace $1 {", 40 | " export type Encoded = Encoded & UnionBrand", 41 | "}" 42 | ] 43 | }, 44 | "Request": { 45 | "prefix": "req", 46 | "body": [ 47 | "export class $1 extends S.Req<$1>()(\"$1\", {", 48 | " $2", 49 | "}, { success: $3 }) {}", 50 | "" 51 | ], 52 | "description": "Defines a Request signature" 53 | }, 54 | "Response": { 55 | "prefix": "res", 56 | "body": [ 57 | "export class Response extends S.Class", 68 | " Effect.gen(function*() {", 69 | " //const userRepo = yield* UserRepo", 70 | "", 71 | " return matchFor($1Rsc)({", 72 | " $2: $3", 73 | " })", 74 | " })" 75 | ] 76 | } 77 | } -------------------------------------------------------------------------------- /frontend/server/plugins/proxy.ts: -------------------------------------------------------------------------------- 1 | import * as cookie from "cookie" 2 | import httpProxy from "http-proxy-node16" // make sure to use package redirect to "http-proxy-node16" for fixing closing event: https://github.com/http-party/node-http-proxy/pull/1559 3 | import type httpProxyTypes from "http-proxy" 4 | 5 | const proxy = httpProxy as unknown as typeof httpProxyTypes 6 | export default defineNitroPlugin(nitroApp => { 7 | const config = useRuntimeConfig() 8 | 9 | const otlpProxy = proxy.createProxyServer({ 10 | changeOrigin: true, // don't forget this, or you're going to chase your tail for hours 11 | target: "http://localhost:4318", 12 | timeout: 1_000, 13 | }) 14 | 15 | otlpProxy.on("proxyReq", proxyReq => { 16 | proxyReq.path = "/v1/traces" 17 | }) 18 | 19 | const apiProxy = proxy.createProxyServer({ 20 | changeOrigin: true, // don't forget this, or you're going to chase your tail for hours 21 | target: config.apiRoot, 22 | }) 23 | 24 | apiProxy.on("proxyReq", (proxyReq, _, res) => { 25 | proxyReq.path = proxyReq.path.replace("/api/api", "") 26 | res.setHeader("x-fe-version", config.public.feVersion) 27 | 28 | const cookieHeader = proxyReq.getHeader("Cookie") 29 | if (!cookieHeader || typeof cookieHeader !== "string") return 30 | 31 | const cookies = cookie.parse(cookieHeader) 32 | const userId = cookies["user-id"] 33 | if (userId) { 34 | proxyReq.setHeader( 35 | "x-user", 36 | JSON.stringify({ sub: userId, "https://nomizz.com/roles": ["user"] }), 37 | ) 38 | } 39 | }) 40 | 41 | nitroApp.h3App.stack.unshift({ 42 | route: "/api/api", 43 | handler: fromNodeMiddleware((req, res, _) => { 44 | apiProxy.web(req, res) 45 | }), 46 | // handler: async event => { 47 | // let accessToken: string | undefined = undefined 48 | // try { 49 | // const jwt = await getToken({ event }) 50 | // if (jwt) { 51 | // accessToken = jwt.access_token as string | undefined 52 | // } 53 | // } catch (error) { 54 | // console.error(error) 55 | // } 56 | // return await fromNodeMiddleware((req, res, _) => { 57 | // if (accessToken) { 58 | // req.headers["authorization"] = "Bearer " + accessToken 59 | // } 60 | // return apiProxy.web(req, res) 61 | // })(event) 62 | // }, 63 | }) 64 | nitroApp.h3App.stack.unshift({ 65 | route: "/api/traces", 66 | handler: fromNodeMiddleware((req, res, _) => { 67 | otlpProxy.web(req, res, { timeout: 1_000 }) 68 | }), 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@effect-app-boilerplate/frontend", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "build": "nuxt build", 7 | "compile": "([ ! -d './.nuxt' ] && nuxt prepare || echo 'no prepare') && vue-tsc --noEmit", 8 | "watch": "pnpm compile -w", 9 | "dev": "PORT=4000 effect-app-cli watch nuxt dev --host", 10 | "generate": "nuxt generate", 11 | "preview": "nuxt preview", 12 | "start": "node .output/server/index.mjs", 13 | "ncu": "ncu", 14 | "lint": "NODE_OPTIONS=--max-old-space-size=8192 eslint .", 15 | "autofix": "pnpm lint --fix", 16 | "testsuite": "pnpm lint", 17 | "clean": "rm -rf ./.nuxt ./node_modules/.cache" 18 | }, 19 | "dependencies": { 20 | "@effect-app/vue": "^2.41.2", 21 | "@effect/platform": "^0.80.20", 22 | "@effect/platform-browser": "^0.60.11", 23 | "@effect/rpc": "^0.56.8", 24 | "@effect/rpc-http": "^0.52.4", 25 | "@effect/opentelemetry": "^0.46.17", 26 | "@formatjs/intl": "3.1.6", 27 | "@hebilicious/vue-query-nuxt": "^0.3.0", 28 | "@opentelemetry/context-zone": "^2.0.0", 29 | "@opentelemetry/exporter-collector": "^0.25.0", 30 | "@opentelemetry/propagator-b3": "^2.0.0", 31 | "@opentelemetry/tracing": "^0.24.0", 32 | "@opentelemetry/web": "^0.24.0", 33 | "@sentry/browser": "9.14.0", 34 | "@sentry/opentelemetry": "9.14.0", 35 | "@sentry/opentelemetry-node": "^7.114.0", 36 | "@sentry/tracing": "^7.120.3", 37 | "@sentry/vite-plugin": "^3.3.1", 38 | "@sentry/vue": "9.14.0", 39 | "@tanstack/vue-query": "^5.75.5", 40 | "@tanstack/vue-query-devtools": "^5.75.5", 41 | "@vueuse/core": "^13.1.0", 42 | "@vueuse/nuxt": "^13.1.0", 43 | "cookie": "^1.0.2", 44 | "date-fns": "^4.1.0", 45 | "effect": "^3.14.20", 46 | "effect-app": "^2.40.1", 47 | "highcharts": "^12.2.0", 48 | "http-proxy-node16": "^1.0.6", 49 | "mitt": "^3.0.1", 50 | "papaparse": "^5.5.2", 51 | "reconnecting-eventsource": "^1.6.4", 52 | "vue-markdown-render": "^2.2.1", 53 | "vue-timeago3": "^2.3.2", 54 | "vue-toastification": "^2.0.0-rc.5", 55 | "vuetify": "^3.8.4", 56 | "xlsx": "^0.18.5" 57 | }, 58 | "devDependencies": { 59 | "@effect-app-boilerplate/api": "workspace:*", 60 | "@mdi/js": "^7.4.47", 61 | "@types/cookie": "^1.0.0", 62 | "@types/http-proxy": "^1.17.16", 63 | "@types/markdown-it": "^14.1.2", 64 | "eslint-plugin-vue": "^10.1.0", 65 | "h3": "^1.15.3", 66 | "nuxt": "~3.17.2", 67 | "sass": "^1.87.0", 68 | "typescript": "~5.8.3", 69 | "vue-tsc": "2.2.10" 70 | } 71 | } -------------------------------------------------------------------------------- /frontend/composables/intl.ts: -------------------------------------------------------------------------------- 1 | import { makeIntl } from "@effect-app/vue" 2 | 3 | const messages = { 4 | de: { 5 | "handle.success": "{action} erfolgreich", 6 | "handle.with_errors": "{action} fehlgeschlagen", 7 | "handle.with_warnings": "{action} erfolgreich, mit Warnungen", 8 | "handle.error_response": 9 | "Die Anfrage war nicht erfolgreich:\n{error}\nWir wurden benachrichtigt und werden das Problem in Kürze beheben.", 10 | "handle.response_error": 11 | "Die Antwort konnte nicht verarbeitet werden:\n{error}", 12 | "handle.request_error": 13 | "Die Anfrage konnte nicht gesendet werden:\n{error}", 14 | "handle.unexpected_error": "Unerwarteter Fehler:\n{error}", 15 | 16 | "validation.empty": `Das Feld darf nicht leer sein`, 17 | "validation.number.max": 18 | "Der Wert sollte {isExclusive, select, true {kleiner als} other {höchstens}} {maximum} sein", 19 | "validation.number.min": `Der Wert sollte {isExclusive, select, true {größer als} other {mindestens}} {minimum} sein`, 20 | "validation.string.maxLength": `Das Feld darf nicht mehr als {maxLength} Zeichen haben`, 21 | "validation.string.minLength": `Das Feld muss mindestens {minLength} Zeichen enthalten`, 22 | "validation.not_a_valid": `Der eingegebene Wert ist kein gültiger {type}: {message}`, 23 | "validation.failed": "Ungültige Eingabe", 24 | }, 25 | en: { 26 | "handle.success": "{action} Success", 27 | "handle.with_errors": "{action} Failed", 28 | "handle.with_warnings": "{action}, with warnings", 29 | "handle.error_response": 30 | "There was an error in processing the response:\n{error}\nWe have been notified and will fix the problem shortly.", 31 | "handle.request_error": "There was an error in the request:\n{error}", 32 | "handle.response_error": "The request was not successful:\n{error}", 33 | "handle.unexpected_error": "Unexpected Error:\n{error}", 34 | 35 | "validation.empty": "The field cannot be empty", 36 | "validation.number.max": 37 | "The value should be {isExclusive, select, true {smaller than} other {at most}} {maximum}", 38 | "validation.number.min": 39 | "The value should be {isExclusive, select, true {larger than} other {at least}} {minimum}", 40 | "validation.string.maxLength": 41 | "The field cannot have more than {maxLength} characters", 42 | "validation.string.minLength": 43 | "The field requires at least {minLength} characters", 44 | "validation.not_a_valid": 45 | "The entered value is not a valid {type}: {message}", 46 | "validation.failed": "Invalid input", 47 | }, 48 | } as const 49 | 50 | export const { LocaleContext, useIntl } = makeIntl(messages, "de") 51 | -------------------------------------------------------------------------------- /frontend/plugins/runtime.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { initializeSync } from "@effect-app/vue/runtime" 3 | import * as Layer from "effect/Layer" 4 | import * as Runtime from "effect/Runtime" 5 | import { Effect, Option } from "effect-app" 6 | import { WebSdkLive } from "~/utils/observability" 7 | import "effect-app/builtin" 8 | import { ref, shallowRef } from "vue" 9 | import { HttpClient } from "effect-app/http" 10 | import { FetchHttpClient } from "@effect/platform" 11 | import { ApiClientFactory } from "effect-app/client/apiClientFactory" 12 | import { defineNuxtPlugin, useRuntimeConfig } from "nuxt/app" 13 | 14 | export const versionMatch = ref(true) 15 | 16 | export const runtime = shallowRef>() 17 | 18 | function makeRuntime(feVersion: string, disableTracing: boolean) { 19 | const apiLayers = ApiClientFactory.layer({ 20 | url: "/api/api", 21 | headers: Option.none(), 22 | }).pipe( 23 | Layer.provide( 24 | Layer.effect( 25 | HttpClient.HttpClient, 26 | Effect.map( 27 | HttpClient.HttpClient, 28 | HttpClient.tap(r => 29 | Effect.sync(() => { 30 | const remoteFeVersion = r.headers["x-fe-version"] 31 | if (remoteFeVersion) { 32 | versionMatch.value = feVersion === remoteFeVersion 33 | } 34 | }), 35 | ), 36 | ), 37 | ), 38 | ), 39 | Layer.provide(FetchHttpClient.layer), 40 | Layer.provide( 41 | Layer.succeed(FetchHttpClient.RequestInit, { credentials: "include" }), 42 | ), 43 | ) 44 | 45 | const rt: { 46 | runtime: Runtime.Runtime 47 | clean: () => void 48 | } = initializeSync( 49 | // TODO: tracing when deployed 50 | disableTracing 51 | ? apiLayers 52 | : apiLayers.pipe( 53 | Layer.merge( 54 | WebSdkLive({ 55 | serviceName: "effect-app-boilerplate-frontend", 56 | serviceVersion: feVersion, 57 | attributes: {}, 58 | }), 59 | ), 60 | ), 61 | ) 62 | return { 63 | ...rt, 64 | runFork: Runtime.runFork(rt.runtime), 65 | runSync: Runtime.runSync(rt.runtime), 66 | runPromise: Runtime.runPromise(rt.runtime), 67 | runCallback: Runtime.runCallback(rt.runtime), 68 | } 69 | } 70 | 71 | // TODO: make sure the runtime provides these 72 | export type RT = ApiClientFactory 73 | 74 | export default defineNuxtPlugin(_ => { 75 | const config = useRuntimeConfig() 76 | 77 | const rt = makeRuntime( 78 | config.public.feVersion, 79 | config.public.env !== "local-dev" || !config.public.telemetry, 80 | ) 81 | 82 | runtime.value = rt 83 | }) 84 | -------------------------------------------------------------------------------- /api/src/Blog.controllers.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "#lib/routing" 2 | import { BlogPost } from "#models/Blog" 3 | import { BlogRsc } from "#resources" 4 | import { BogusEvent } from "#resources/Events" 5 | import { BlogPostRepo, Events, Operations, UserRepo } from "#services" 6 | import { Duration, Effect, Schedule } from "effect" 7 | import { Option } from "effect-app" 8 | import { NonEmptyString2k, NonNegativeInt } from "effect-app/Schema" 9 | import { OperationsDefault } from "./lib/layers.js" 10 | 11 | export default Router(BlogRsc)({ 12 | dependencies: [ 13 | BlogPostRepo.Default, 14 | UserRepo.Default, 15 | OperationsDefault, 16 | Events.Default 17 | ], 18 | *effect(match) { 19 | const blogPostRepo = yield* BlogPostRepo 20 | const userRepo = yield* UserRepo 21 | const events = yield* Events 22 | const operations = yield* Operations 23 | 24 | return match({ 25 | FindPost: (req) => 26 | blogPostRepo 27 | .find(req.id) 28 | .pipe(Effect.andThen(Option.getOrNull)), 29 | GetPosts: blogPostRepo 30 | .all 31 | .pipe(Effect.andThen((items) => ({ items }))), 32 | CreatePost: (req) => 33 | userRepo 34 | .getCurrentUser 35 | .pipe( 36 | Effect.andThen((author) => (new BlogPost({ ...req, author }, true))), 37 | Effect.tap(blogPostRepo.save) 38 | ), 39 | *PublishPost(req) { 40 | const post = yield* blogPostRepo.get(req.id) 41 | 42 | console.log("publishing post", post) 43 | 44 | const targets = [ 45 | "google", 46 | "twitter", 47 | "facebook" 48 | ] 49 | 50 | const done: string[] = [] 51 | 52 | const op = yield* operations.fork( 53 | (opId) => 54 | operations 55 | .update(opId, { 56 | total: NonNegativeInt(targets.length), 57 | completed: NonNegativeInt(done.length) 58 | }) 59 | .pipe( 60 | Effect.andThen(Effect.forEach(targets, (_) => 61 | Effect 62 | .sync(() => done.push(_)) 63 | .pipe( 64 | Effect.tap(() => 65 | operations.update(opId, { 66 | total: NonNegativeInt(targets.length), 67 | completed: NonNegativeInt(done.length) 68 | }) 69 | ), 70 | Effect.delay(Duration.seconds(4)) 71 | ))), 72 | Effect.andThen(() => "the answer to the universe is 41") 73 | ), 74 | // while operation is running... 75 | (_opId) => 76 | Effect 77 | .suspend(() => events.publish(new BogusEvent())) 78 | .pipe(Effect.schedule(Schedule.spaced(Duration.seconds(1)))), 79 | NonEmptyString2k("post publishing") 80 | ) 81 | 82 | return op.id 83 | } 84 | }) 85 | } 86 | }) 87 | -------------------------------------------------------------------------------- /api/src/router.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import * as MW from "#lib/middleware" 3 | import { Events } from "#services" 4 | import { Router } from "@effect-app/infra/api/routing" 5 | import { reportError } from "@effect-app/infra/errorReporter" 6 | import { RpcSerialization } from "@effect/rpc" 7 | import { FiberRef, flow } from "effect" 8 | import { Console, Effect, Layer } from "effect-app" 9 | import { HttpMiddleware, HttpRouter, HttpServer } from "effect-app/http" 10 | import { BaseConfig, MergedConfig } from "./config.js" 11 | 12 | const prodOrigins: string[] = [] 13 | const demoOrigins: string[] = [] 14 | 15 | const localOrigins = [ 16 | "http://localhost:4000" 17 | ] 18 | 19 | const RootRoutes = ( 20 | rpcRoutes: Layer 21 | ) => 22 | HttpRouter 23 | .Default 24 | .use(Effect.fnUntraced(function*(router) { 25 | const cfg = yield* BaseConfig 26 | const { env } = yield* BaseConfig 27 | const rpcRouter = yield* Router.router 28 | const handleEvents = yield* MW.makeEvents 29 | 30 | const middleware = flow( 31 | // MW.authTokenFromCookie(secret), 32 | MW.RequestContextMiddleware(), 33 | MW.gzip, 34 | MW.cors({ 35 | credentials: true, 36 | allowedOrigins: env === "demo" 37 | ? (origin) => demoOrigins.includes(origin) 38 | : env === "prod" 39 | ? prodOrigins 40 | : localOrigins 41 | }), 42 | // we trust proxy and handle the x-forwarded etc headers 43 | HttpMiddleware.xForwardedHeaders 44 | ) 45 | 46 | yield* router.get( 47 | "/.well-known/local/server-health", 48 | MW.serverHealth(cfg.apiVersion).pipe(Effect.tapErrorCause(reportError("server-health error"))) 49 | ) 50 | yield* router.mountApp( 51 | "/rpc", 52 | rpcRouter.pipe(middleware) 53 | ) 54 | yield* router.get("/events", handleEvents.pipe(Effect.tapErrorCause(reportError("events error")), middleware)) 55 | })) 56 | .pipe(Layer.provide([Events.Default, Router.Live.pipe(Layer.provide(rpcRoutes))])) 57 | 58 | const logServer = Effect 59 | .gen(function*() { 60 | const cfg = yield* MergedConfig 61 | // using Console.log for vscode to know we're ready 62 | yield* Console.log( 63 | `Running on http://${cfg.host}:${cfg.port} at version: ${cfg.apiVersion}. ENV: ${cfg.env}` 64 | ) 65 | }) 66 | .pipe(Layer.effectDiscard) 67 | 68 | const ConfigureTracer = Layer.effectDiscard( 69 | FiberRef.set( 70 | HttpMiddleware.currentTracerDisabledWhen, 71 | (r) => r.method === "OPTIONS" || r.url === "/.well-known/local/server-health" 72 | ) 73 | ) 74 | export const makeHttpServer = ( 75 | rpcRouter: Layer 76 | ) => 77 | logServer.pipe( 78 | Layer.provide(HttpRouter.Default.unwrap(HttpServer.serve(HttpMiddleware.logger))), 79 | Layer.provide(RootRoutes(rpcRouter)), 80 | Layer.provide(RpcSerialization.layerJson), 81 | Layer.provide(ConfigureTracer) 82 | ) 83 | -------------------------------------------------------------------------------- /vite.config.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import fs from "fs" 4 | import path from "path" 5 | import AutoImport from "unplugin-auto-import/vite" 6 | import { defineConfig } from "vite" 7 | import type { UserConfig } from "vite" 8 | import makeConfig from "./vite.config.base" 9 | 10 | const pj = require("./package.json") 11 | 12 | const basePj = pj.name.replace("/root", "") 13 | 14 | export default function defineTestConfig( 15 | dirName?: string, 16 | transform?: (cfg: UserConfig, useDist: boolean, useFullDist: boolean) => UserConfig, 17 | options: { useTransform?: boolean; useFullDist?: boolean; useDist?: boolean } = { 18 | useTransform: false, 19 | useDist: process.env.TEST_USE_DIST === "true", 20 | useFullDist: process.env.TEST_USE_FULL_DIST === "true" 21 | } 22 | ) { 23 | let { 24 | useDist = process.env.TEST_USE_DIST === "true", 25 | // eslint-disable-next-line prefer-const 26 | useFullDist = process.env.TEST_USE_FULL_DIST === "true", 27 | // eslint-disable-next-line prefer-const 28 | useTransform = false 29 | } = options 30 | if (useFullDist) { 31 | useDist = true 32 | } 33 | 34 | 35 | const b = makeConfig(dirName, useDist, useTransform) 36 | // autoimport seems to work best, even if in some cases setting vitest/globals as types works. 37 | const autoImport = AutoImport({ 38 | dts: "./test/auto-imports.d.ts", 39 | // include: [ 40 | // /\.test\.[tj]sx?$/ // .ts, .tsx, .js, .jsx 41 | // ], 42 | imports: [ 43 | "vitest", 44 | { 45 | "@effect-app/infra/vitest": [ 46 | "describe", 47 | "it", 48 | "expect", 49 | "beforeAll", 50 | "afterAll", 51 | "beforeEach", 52 | "afterEach", 53 | 54 | "layer", 55 | 56 | "createRandomInstance", 57 | "createRandomInstanceI", 58 | 59 | "assert", 60 | "suite", 61 | "test" 62 | ] 63 | } 64 | ] 65 | }) 66 | 67 | const d = dirName ? dirName + "/" : "" 68 | const cfg = { 69 | ...b, 70 | // eslint-disable-next-line @typescript-eslint/no-var-requires 71 | plugins: [ 72 | ...b.plugins ?? [], 73 | ...useFullDist 74 | ? [autoImport] 75 | : [ 76 | ...useTransform 77 | ? [ 78 | require("@effect-app/compiler/vitePlugin2").effectPlugin({ 79 | tsconfig: fs.existsSync(d + "tsconfig.test.local.json") 80 | ? d + "tsconfig.test.local.json" 81 | : d + "tsconfig.test.json" 82 | }) 83 | ] 84 | : [], 85 | autoImport 86 | ] 87 | ], 88 | test: { 89 | ...b.test, 90 | include: useFullDist 91 | ? ["./test/**/*.test.{js,mjs,cjs,jsx}"] 92 | : ["./test/**/*.test.{ts,mts,cts,tsx}"], 93 | exclude: ["**/node_modules/**"] 94 | }, 95 | watchExclude: ["**/node_modules/**"] 96 | // forceRerunTriggers: ['**/package.json/**', '**/vitest.config.*/**', '**/vite.config.*/**', '**/dist/**'] 97 | } 98 | // console.log("cfg", cfg) 99 | return defineConfig(transform ? transform(cfg, useDist, useFullDist) : cfg) 100 | } 101 | -------------------------------------------------------------------------------- /api/src/models/User.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/unbound-method */ 2 | import { Context, Effect, Equivalence, pipe, S, type Schema } from "effect-app" 3 | import { fakerArb } from "effect-app/faker" 4 | import { UserProfileId } from "effect-app/ids" 5 | import { AST } from "effect-app/Schema" 6 | import type * as A from "effect/Arbitrary" 7 | 8 | export const FirstName = S 9 | .NonEmptyString255 10 | .pipe( 11 | S.annotations({ 12 | [AST.ArbitraryAnnotationId]: (): A.LazyArbitrary => (fc) => fc.string() 13 | }), 14 | S.withDefaultMake 15 | ) 16 | 17 | export type FirstName = Schema.Type 18 | 19 | export const DisplayName = FirstName 20 | export type DisplayName = Schema.Type 21 | 22 | S.Array(S.NonEmptyString255).pipe( 23 | S.annotations({ [AST.ArbitraryAnnotationId]: (): A.LazyArbitrary> => (fc) => fc.tuple() }) 24 | ) 25 | 26 | export const LastName = S 27 | .NonEmptyString255 28 | .pipe( 29 | S.annotations({ 30 | [AST.ArbitraryAnnotationId]: (): A.LazyArbitrary => fakerArb((faker) => faker.person.lastName) 31 | }), 32 | S.withDefaultMake 33 | ) 34 | 35 | export type LastName = Schema.Type 36 | 37 | export class FullName extends S.ExtendedClass()({ 38 | firstName: FirstName, 39 | lastName: LastName 40 | }) { 41 | static render(this: void, fn: FullName) { 42 | return S.NonEmptyString2k(`${fn.firstName} ${fn.lastName}`) 43 | } 44 | 45 | static create(this: void, firstName: FirstName, lastName: LastName) { 46 | return new FullName({ firstName, lastName }) 47 | } 48 | } 49 | 50 | export function showFullName(fn: FullName) { 51 | return FullName.render(fn) 52 | } 53 | 54 | export function createFullName(firstName: string, lastName: string) { 55 | return { firstName, lastName } 56 | } 57 | 58 | export const UserId = UserProfileId 59 | export type UserId = UserProfileId 60 | 61 | export const Role = S.withDefaultMake(S.Literal("manager", "user")) 62 | export type Role = Schema.Type 63 | 64 | export class UserFromIdResolver 65 | extends Context.TagId("UserFromId") Effect }>() 66 | {} 67 | 68 | export class User extends S.ExtendedClass()({ 69 | id: UserId.withDefault, 70 | name: FullName, 71 | email: S.Email, 72 | role: Role, 73 | passwordHash: S.NonEmptyString255 74 | }) { 75 | get displayName() { 76 | return S.NonEmptyString2k(this.name.firstName + " " + this.name.lastName) 77 | } 78 | static readonly resolver = UserFromIdResolver 79 | } 80 | 81 | export const UserFromId: Schema = S.transformOrFail( 82 | UserId, 83 | S.typeSchema(User), 84 | { decode: User.resolver.get, encode: (u) => Effect.succeed(u.id) } 85 | ) 86 | 87 | export const defaultEqual = pipe(Equivalence.string, Equivalence.mapInput((u: User) => u.id)) 88 | 89 | // codegen:start {preset: model} 90 | // 91 | /* eslint-disable */ 92 | export namespace FullName { 93 | export interface Encoded extends S.Struct.Encoded {} 94 | } 95 | export namespace User { 96 | export interface Encoded extends S.Struct.Encoded {} 97 | } 98 | /* eslint-enable */ 99 | // 100 | // codegen:end 101 | // 102 | -------------------------------------------------------------------------------- /api/src/resources/Operations.ts: -------------------------------------------------------------------------------- 1 | import { Duration, Effect } from "effect-app" 2 | import { NotFoundError } from "effect-app/client/errors" 3 | import { Operation, OperationFailure, OperationId } from "effect-app/Operations" 4 | import { clientFor } from "./lib.js" 5 | import * as S from "./lib/schema.js" 6 | 7 | export class FindOperation extends S.Req()("FindOperation", { 8 | id: OperationId 9 | }, { allowAnonymous: true, allowRoles: ["user"], success: S.NullOr(Operation) }) {} 10 | 11 | // codegen:start {preset: meta, sourcePrefix: src/resources/} 12 | export const meta = { moduleName: "Operations" } as const 13 | // codegen:end 14 | 15 | // Extensions 16 | export const OperationsClient = Effect.gen(function*() { 17 | const opsClient = yield* clientFor({ FindOperation, meta }) 18 | 19 | function refreshAndWaitAForOperation( 20 | refresh: Effect, 21 | cb?: (op: Operation) => void 22 | ) { 23 | return (act: Effect) => 24 | Effect.tap( 25 | waitForOperation( 26 | Effect.tap(act, refresh), 27 | cb 28 | ), 29 | refresh 30 | ) 31 | } 32 | 33 | function refreshAndWaitAForOperation_( 34 | act: Effect, 35 | refresh: Effect, 36 | cb?: (op: Operation) => void 37 | ) { 38 | return Effect.tap( 39 | waitForOperation( 40 | Effect.tap(act, refresh), 41 | cb 42 | ), 43 | refresh 44 | ) 45 | } 46 | 47 | function refreshAndWaitForOperation(refresh: Effect, cb?: (op: Operation) => void) { 48 | return (act: (req: Req) => Effect) => (req: Req) => 49 | refreshAndWaitAForOperation_(act(req), refresh, cb) 50 | } 51 | 52 | function refreshAndWaitForOperation_( 53 | act: (req: Req) => Effect, 54 | refresh: Effect, 55 | cb?: (op: Operation) => void 56 | ) { 57 | return (req: Req) => refreshAndWaitAForOperation_(act(req), refresh, cb) 58 | } 59 | 60 | function waitForOperation( 61 | self: Effect, 62 | cb?: (op: Operation) => void 63 | ) { 64 | return Effect.andThen(self, (r) => _waitForOperation(r, cb)) 65 | } 66 | 67 | function waitForOperation_(cb?: (op: Operation) => void) { 68 | return (self: (req: Req) => Effect) => (req: Req) => 69 | Effect.andThen(self(req), (r) => _waitForOperation(r, cb)) 70 | } 71 | 72 | const isFailure = S.is(OperationFailure) 73 | 74 | function _waitForOperation(id: OperationId, cb?: (op: Operation) => void) { 75 | return Effect 76 | .gen(function*() { 77 | let r = yield* opsClient.FindOperation.handler({ id }) 78 | while (r) { 79 | if (cb) cb(r) 80 | const result = r.result 81 | if (result) return isFailure(result) ? yield* Effect.fail(result) : yield* Effect.succeed(result) 82 | yield* Effect.sleep(Duration.seconds(2)) 83 | r = yield* opsClient.FindOperation.handler({ id }) 84 | } 85 | return yield* new NotFoundError({ type: "Operation", id }) 86 | }) 87 | // .pipe(Effect.provide(Layer.setRequestCaching(false))) 88 | } 89 | 90 | return Object.assign(opsClient, { 91 | refreshAndWaitAForOperation, 92 | refreshAndWaitAForOperation_, 93 | refreshAndWaitForOperation, 94 | refreshAndWaitForOperation_, 95 | waitForOperation, 96 | waitForOperation_ 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /frontend/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import process from "process" 2 | import { fileURLToPath } from "url" 3 | import fs from "fs" 4 | 5 | import rootPj from "../package.json" 6 | 7 | const getPath = (pack: string) => { 8 | const isAbsolute = (rootPj.resolutions as any)[pack].startsWith("file:/") 9 | let pathStr = (rootPj.resolutions as any)[pack].replace( 10 | "file:", 11 | isAbsolute ? "/" : "../", 12 | ) 13 | if (isAbsolute) { 14 | const currentPath = __dirname 15 | const pathParts = currentPath.split("/") 16 | const depth = pathParts.length 17 | pathStr = "../".repeat(depth) + pathStr.substring(1) 18 | } 19 | return fileURLToPath(new URL(pathStr, import.meta.url)) 20 | } 21 | 22 | // use `pnpm effa link` in the root project 23 | // `pnpm effa unlink` to revert 24 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 | const localLibs = !!(rootPj.resolutions as any)["effect-app"] 26 | 27 | // https://v3.nuxtjs.org/api/configuration/nuxt.config 28 | export default defineNuxtConfig({ 29 | typescript: { 30 | tsConfig: { compilerOptions: { moduleResolution: "bundler" } }, 31 | }, 32 | 33 | sourcemap: { 34 | server: true, 35 | client: true, 36 | }, 37 | 38 | alias: { 39 | "#resources": fileURLToPath( 40 | new URL("../api/src/resources", import.meta.url), 41 | ), 42 | "#models": fileURLToPath(new URL("../api/src/models", import.meta.url)), 43 | ...(localLibs 44 | ? { 45 | effect: getPath("effect") + "/dist/esm", 46 | "effect-app": getPath("effect-app") + "/dist", 47 | "@effect-app/vue": getPath("@effect-app/vue") + "/src", 48 | "@effect-app/vue-components": getPath("@effect-app/vue-components"), 49 | } 50 | : {}), 51 | }, 52 | 53 | build: { 54 | transpile: ["vuetify"] 55 | // workaround for commonjs/esm module prod issue 56 | // https://github.com/nuxt/framework/issues/7698 57 | .concat( 58 | process.env["NODE_ENV"] === "production" ? ["vue-toastification"] : [], 59 | ), 60 | }, 61 | 62 | runtimeConfig: { 63 | basicAuthCredentials: "", 64 | apiRoot: "http://127.0.0.1:3610", 65 | public: { 66 | telemetry: 67 | fs.existsSync("../.telemetry-exporter-running") && 68 | fs.readFileSync("../.telemetry-exporter-running", "utf-8") === "true", 69 | baseUrl: "http://localhost:4000", 70 | feVersion: "-1", 71 | env: process.env["ENV"] ?? "local-dev", 72 | }, 73 | }, 74 | 75 | modules: ["@vueuse/nuxt", "@hebilicious/vue-query-nuxt"], 76 | 77 | // app doesn't need SSR, but also it causes problems with linking schema package. 78 | ssr: false, 79 | 80 | vite: { 81 | build: { 82 | minify: "terser", 83 | terserOptions: { keep_classnames: true }, 84 | sourcemap: true, 85 | }, 86 | optimizeDeps: { 87 | // noDiscovery: true, // this breaks; "validator/lib/isEmail.js" has no default export 88 | include: [ 89 | "@mdi/js", 90 | "@unhead/vue", 91 | "reconnecting-eventsource", 92 | "mitt", 93 | "@tanstack/vue-query", 94 | "effect-app/utils", 95 | "@effect-app/vue/routeParams", 96 | "@effect-app/vue/form", 97 | ], 98 | }, 99 | plugins: process.env["CI"] 100 | ? [ 101 | // sentryVitePlugin({ 102 | // org: "???", 103 | // project: "effect-app-boilerplate-api", 104 | // authToken: "???", 105 | // sourcemaps: { 106 | // assets: "./.nuxt/dist/**", 107 | // }, 108 | // debug: true, 109 | // }), 110 | ] 111 | : [], 112 | }, 113 | 114 | compatibilityDate: "2024-09-04", 115 | }) 116 | -------------------------------------------------------------------------------- /api/src/services/DBContext/UserRepo.ts: -------------------------------------------------------------------------------- 1 | import { RepoConfig } from "#config" 2 | import { RepoDefault } from "#lib/layers" 3 | import { User, type UserId } from "#models/User" 4 | import { Model } from "@effect-app/infra" 5 | import { NotFoundError, NotLoggedInError } from "@effect-app/infra/errors" 6 | import { generate } from "@effect-app/infra/test" 7 | import { Array, Effect, Exit, Layer, Option, pipe, Request, RequestResolver, S } from "effect-app" 8 | import { fakerArb } from "effect-app/faker" 9 | import { Email } from "effect-app/Schema" 10 | import fc from "fast-check" 11 | import { Q } from "../lib.js" 12 | import { UserProfile } from "../UserProfile.js" 13 | 14 | export type UserSeed = "sample" | "" 15 | 16 | export class UserRepo extends Effect.Service()("UserRepo", { 17 | dependencies: [RepoDefault], 18 | effect: Effect.gen(function*() { 19 | const cfg = yield* RepoConfig 20 | 21 | const makeInitial = yield* Effect.cached(Effect.sync(() => { 22 | const seed = cfg.fakeUsers === "seed" ? "seed" : cfg.fakeUsers === "sample" ? "sample" : "" 23 | const fakeUsers = pipe( 24 | Array 25 | .range(1, 8) 26 | .map((_, i): User => { 27 | const g = generate(S.A.make(User)).value 28 | const emailArb = fakerArb((_) => () => 29 | _ 30 | .internet 31 | .exampleEmail({ firstName: g.name.firstName, lastName: g.name.lastName }) 32 | ) 33 | return new User({ 34 | ...g, 35 | email: Email(generate(emailArb(fc)).value), 36 | role: i === 0 || i === 1 ? "manager" : "user" 37 | }) 38 | }), 39 | Array.toNonEmptyArray, 40 | Option 41 | .match({ 42 | onNone: () => { 43 | throw new Error("must have fake users") 44 | }, 45 | onSome: (_) => _ 46 | }) 47 | ) 48 | const items = seed === "sample" ? fakeUsers : [] 49 | return items 50 | })) 51 | 52 | return yield* Model.makeRepo("User", User, { makeInitial }) 53 | }) 54 | }) { 55 | get tryGetCurrentUser() { 56 | return Effect.serviceOption(UserProfile).pipe( 57 | Effect.andThen((_) => _.pipe(Effect.mapError(() => new NotLoggedInError()))), 58 | Effect.andThen((_) => this.get(_.sub)) 59 | ) 60 | } 61 | 62 | get getCurrentUser() { 63 | return UserProfile.pipe( 64 | Effect.andThen((_) => this.get(_.sub)) 65 | ) 66 | } 67 | 68 | static readonly getUserByIdResolver = RequestResolver 69 | .makeBatched((requests: GetUserById[]) => 70 | this.use((_) => 71 | _ 72 | .query(Q.where("id", "in", requests.map((_) => _.id))) 73 | .pipe(Effect.andThen((users) => 74 | Effect.forEach(requests, (r) => 75 | Request.complete( 76 | r, 77 | Array 78 | .findFirst(users, (_) => _.id === r.id ? Option.some(Exit.succeed(_)) : Option.none()) 79 | .pipe(Option.getOrElse(() => Exit.fail(new NotFoundError({ type: "User", id: r.id })))) 80 | ), { discard: true }) 81 | )) 82 | ).pipe( 83 | Effect.orDie, 84 | Effect.catchAllCause((cause) => 85 | Effect.forEach( 86 | requests, 87 | (request) => Request.failCause(request, cause), 88 | { discard: true } 89 | ) 90 | )) 91 | ) 92 | .pipe( 93 | RequestResolver.batchN(25), 94 | RequestResolver.contextFromServices(UserRepo) 95 | ) 96 | static readonly UserFromIdLayer = User 97 | .resolver 98 | .toLayer( 99 | this.use((userRepo) => 100 | this 101 | .getUserByIdResolver 102 | .pipe( 103 | Effect.provideService(this, userRepo), 104 | Effect.map((resolver) => ({ 105 | get: (id: UserId) => 106 | Effect 107 | .request(GetUserById({ id }), resolver) 108 | .pipe(Effect.orDie) 109 | })) 110 | ) 111 | ) 112 | ) 113 | .pipe(Layer.provide(this.Default)) 114 | } 115 | 116 | interface GetUserById extends Request.Request> { 117 | readonly _tag: "GetUserById" 118 | readonly id: UserId 119 | } 120 | const GetUserById = Request.tagged("GetUserById") 121 | -------------------------------------------------------------------------------- /api/src/lib/basicRuntime.ts: -------------------------------------------------------------------------------- 1 | import { AppLogger } from "#lib/logger" 2 | import { reportError } from "@effect-app/infra/errorReporter" 3 | import { logJson } from "@effect-app/infra/logger/jsonLogger" 4 | import { PlatformLogger } from "@effect/platform" 5 | import { NodeFileSystem } from "@effect/platform-node" 6 | import { defaultTeardown, type RunMain, type Teardown } from "@effect/platform/Runtime" 7 | import * as Sentry from "@sentry/node" 8 | import { constantCase } from "change-case" 9 | import { Cause, Console, Effect, Fiber, Layer, ManagedRuntime } from "effect-app" 10 | import { dual } from "effect-app/Function" 11 | import * as ConfigProvider from "effect/ConfigProvider" 12 | import * as Logger from "effect/Logger" 13 | import * as Level from "effect/LogLevel" 14 | import type * as Runtime from "effect/Runtime" 15 | 16 | const envProviderConstantCase = ConfigProvider.mapInputPath( 17 | ConfigProvider.fromEnv({ 18 | pathDelim: "_", // i'd prefer "__" 19 | seqDelim: "," 20 | }), 21 | constantCase 22 | ) 23 | 24 | const levels = { 25 | [Level.Trace.label]: Level.Trace, 26 | [Level.Debug.label]: Level.Debug, 27 | [Level.Info.label]: Level.Info, 28 | [Level.Warning.label]: Level.Warning, 29 | [Level.Error.label]: Level.Error 30 | } 31 | 32 | const configuredLogLevel = process.env["LOG_LEVEL"] 33 | const configuredEnv = process.env["ENV"] 34 | 35 | const logLevel = configuredLogLevel 36 | ? levels[configuredLogLevel] 37 | : configuredEnv && configuredEnv === "prod" 38 | ? Level.Info 39 | : Level.Debug 40 | if (!logLevel) throw new Error(`Invalid LOG_LEVEL: ${configuredLogLevel}`) 41 | 42 | const devLog = Logger 43 | .withSpanAnnotations(Logger.logfmtLogger) 44 | .pipe( 45 | PlatformLogger.toFile("./dev.log") 46 | ) 47 | 48 | const addDevLog = Logger.addScoped(devLog).pipe(Layer.provide(NodeFileSystem.layer)) 49 | 50 | export const basicLayer = Layer.mergeAll( 51 | Logger.minimumLogLevel(logLevel), 52 | Effect 53 | .sync(() => 54 | configuredEnv && configuredEnv !== "local-dev" 55 | ? logJson 56 | : process.env["NO_CONSOLE_LOG"] 57 | ? Layer.mergeAll( 58 | Logger.remove(Logger.defaultLogger), 59 | addDevLog 60 | ) 61 | : Layer.mergeAll( 62 | Logger.replace(Logger.defaultLogger, Logger.withSpanAnnotations(Logger.prettyLogger())), 63 | addDevLog 64 | ) 65 | ) 66 | .pipe(Layer.unwrapEffect), 67 | Layer.setConfigProvider(envProviderConstantCase) 68 | ) 69 | 70 | export const basicRuntime = ManagedRuntime.make(basicLayer) 71 | await basicRuntime.runtime() 72 | 73 | const reportMainError = (cause: Cause.Cause) => 74 | Cause.isInterruptedOnly(cause) ? Effect.void : reportError("Main")(cause) 75 | 76 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 77 | const runMainPlatform: RunMain = dual((args) => Effect.isEffect(args[0]), (effect: Effect.Effect, options?: { 78 | readonly disableErrorReporting?: boolean | undefined 79 | readonly disablePrettyLogger?: boolean | undefined 80 | readonly teardown?: Teardown | undefined 81 | }) => { 82 | const teardown = options?.teardown ?? defaultTeardown 83 | const keepAlive = setInterval(() => {}, 2 ** 31 - 1) 84 | 85 | const fiber = Effect.runFork( 86 | options?.disableErrorReporting === true 87 | ? effect 88 | : Effect.tapErrorCause(effect, (cause) => { 89 | if (Cause.isInterruptedOnly(cause)) { 90 | return Effect.void 91 | } 92 | return AppLogger.logError(cause) 93 | }) 94 | ) 95 | 96 | let signaled = !import.meta.hot 97 | 98 | fiber.addObserver((exit) => { 99 | clearInterval(keepAlive) 100 | teardown(exit, (code) => { 101 | if (signaled) process.exit(code) 102 | }) 103 | }) 104 | 105 | function onSigint() { 106 | signaled = true 107 | process.removeListener("SIGINT", onSigint) 108 | process.removeListener("SIGTERM", onSigint) 109 | fiber.unsafeInterruptAsFork(fiber.id()) 110 | } 111 | 112 | process.once("SIGINT", onSigint) 113 | process.once("SIGTERM", onSigint) 114 | 115 | if (import.meta.hot) { 116 | import.meta.hot.accept(async () => {}) 117 | import.meta.hot.dispose(async () => { 118 | await basicRuntime.runPromise(Fiber.interrupt(fiber)) 119 | }) 120 | } 121 | }) 122 | 123 | export function runMain(eff: Effect, filterReport?: (cause: Cause.Cause) => boolean) { 124 | return runMainPlatform( 125 | eff 126 | .pipe( 127 | Effect.tapErrorCause((cause) => !filterReport || filterReport(cause) ? reportMainError(cause) : Effect.void), 128 | Effect.ensuring(basicRuntime.disposeEffect), 129 | Effect.provide(basicLayer), 130 | Effect.ensuring( 131 | Effect 132 | .andThen( 133 | Console.log("Flushing Sentry"), 134 | Effect.promise(() => Sentry.flush(15_000)).pipe(Effect.flatMap((_) => Console.log("Sentry flushed", _))) 135 | ) 136 | ) 137 | ), 138 | { disablePrettyLogger: true, disableErrorReporting: true } 139 | ) 140 | } 141 | 142 | export type RT = typeof basicRuntime.runtime extends Runtime.Runtime ? R : never 143 | -------------------------------------------------------------------------------- /frontend/utils/observability.ts: -------------------------------------------------------------------------------- 1 | import { layer } from "@effect/opentelemetry/WebSdk" 2 | import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http" 3 | import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-web" 4 | import * as Sentry from "@sentry/vue" 5 | import { browserTracingIntegration } from "@sentry/browser" 6 | import { 7 | SentrySpanProcessor, 8 | SentryPropagator, 9 | } from "@sentry/opentelemetry-node" 10 | import type { App } from "vue" 11 | import * as OtelApi from "@opentelemetry/api" 12 | import { isErrorSilenced } from "effect-app/client/errors" 13 | import { Layer, Effect } from "effect-app" 14 | 15 | // import { 16 | // ConsoleSpanExporter, 17 | // SimpleSpanProcessor, 18 | // } from "@opentelemetry/tracing" 19 | // import { CollectorTraceExporter } from "@opentelemetry/exporter-collector" 20 | // import { WebTracerProvider } from "@opentelemetry/web" 21 | // import { ZoneContextManager } from "@opentelemetry/context-zone" 22 | // import { B3Propagator } from "@opentelemetry/propagator-b3" 23 | 24 | // const provider = new WebTracerProvider() 25 | 26 | // // Configure a span processor and exporter for the tracer 27 | // provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter())) 28 | // provider.addSpanProcessor(new SimpleSpanProcessor(new CollectorTraceExporter())) // url is optional and can be omitted - default is http://localhost:55681/v1/trace 29 | 30 | // provider.register({ 31 | // contextManager: new ZoneContextManager(), 32 | // propagator: new B3Propagator(), 33 | // }) 34 | type Primitive = number | string | boolean | bigint | symbol | null | undefined 35 | const annotateTags = (_tags: { [key: string]: Primitive }) => { 36 | // tags["user.role"] = store.user?.role 37 | } 38 | 39 | // watch( 40 | // store, 41 | // ({ user }) => { 42 | // Sentry.setUser({ id: user?.id, username: user?.displayName }) 43 | // }, 44 | // { immediate: true }, 45 | // ) 46 | 47 | export const setupSentry = (app: App, isRemote: boolean) => { 48 | const config = useRuntimeConfig() 49 | Sentry.init({ 50 | app, 51 | environment: config.public.env, 52 | release: config.public.feVersion, 53 | enabled: isRemote, 54 | dsn: "FIXME", 55 | integrations: [ 56 | browserTracingIntegration({ 57 | //routingInstrumentation: Sentry.vueRouterInstrumentation(router), 58 | //tracePropagationTargets: ["localhost", /^\//], 59 | }), 60 | ], 61 | // Set tracesSampleRate to 1.0 to capture 100% 62 | // of transactions for performance monitoring. 63 | // We recommend adjusting this value in production 64 | tracesSampleRate: 1.0, 65 | beforeSendTransaction(event) { 66 | if (event.transaction === "eventsource: receive event") { 67 | return null 68 | } 69 | if (!event.tags) { 70 | event.tags = {} 71 | } 72 | annotateTags(event.tags) 73 | return event 74 | }, 75 | beforeSend(event, hint) { 76 | if ( 77 | // skip handled errors 78 | hint.originalException && 79 | typeof hint.originalException === "object" && 80 | isErrorSilenced(hint.originalException) 81 | ) { 82 | console.warn("Sentry: skipped HandledError", hint.originalException) 83 | return null 84 | } 85 | if (!event.tags) { 86 | event.tags = {} 87 | } 88 | annotateTags(event.tags) 89 | return event 90 | }, 91 | }) 92 | } 93 | 94 | export const WebSdkLive = (resource: { 95 | readonly serviceName: string 96 | readonly serviceVersion: string 97 | readonly attributes: OtelApi.Attributes 98 | }) => 99 | layer(() => ({ 100 | resource, 101 | spanProcessor: [ 102 | new BatchSpanProcessor( 103 | new OTLPTraceExporter({ 104 | headers: {}, // magic here !!! 105 | 106 | url: "http://localhost:4000/api/traces", 107 | }), 108 | ), 109 | ], 110 | })) 111 | 112 | export const SentrySdkLive = ( 113 | resource: { 114 | readonly serviceName: string 115 | readonly serviceVersion: string 116 | readonly attributes: OtelApi.Attributes 117 | }, 118 | _env: string, 119 | ) => 120 | Layer.merge( 121 | Layer.effectDiscard( 122 | Effect.sync(() => { 123 | OtelApi.propagation.setGlobalPropagator(new SentryPropagator()) 124 | }), 125 | ), 126 | layer(() => ({ 127 | resource, 128 | spanProcessor: [new SentrySpanProcessor()], 129 | })), 130 | ) 131 | 132 | // registerInstrumentations({ 133 | // instrumentations: [ 134 | // new FetchInstrumentation({ 135 | // // client is running on port 1234 136 | // ignoreUrls: [/localhost:1234\/sockjs-node/], 137 | // propagateTraceHeaderCorsUrls: ['http://localhost:7777'], 138 | // clearTimingResources: true, 139 | // }), 140 | // ], 141 | // }); 142 | 143 | // const webTracer = provider.getTracer('tracer-web'); 144 | // const singleSpan = webTracer.startSpan(`fetch-span-start`); 145 | 146 | // context.with(setSpan(context.active(), singleSpan), () => { 147 | // // ping an endpoint 148 | // fetch('http://localhost:7777/hello', { 149 | // method: 'GET', 150 | // headers: { 151 | // 'Accept': 'application/json', 152 | // 'Content-Type': 'application/json', 153 | // }, 154 | // }); 155 | // singleSpan.end(); 156 | // }); 157 | -------------------------------------------------------------------------------- /api/src/lib/routing.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-object-type */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | 4 | import { BaseConfig } from "#config" 5 | import { RequestCacheLayers } from "#resources/lib" 6 | import { makeMiddleware, makeRouter } from "@effect-app/infra/api/routing" 7 | import { NotLoggedInError, UnauthorizedError } from "@effect-app/infra/errors" 8 | import type { RequestContext } from "@effect-app/infra/RequestContext" 9 | import { Context, Effect, Exit, Layer, Option, type Request, type S } from "effect-app" 10 | import type { GetEffectContext, RPCContextMap } from "effect-app/client/req" 11 | import { HttpHeaders, HttpServerRequest } from "effect-app/http" 12 | import type * as EffectRequest from "effect/Request" 13 | import { makeUserProfileFromAuthorizationHeader, makeUserProfileFromUserHeader, UserProfile } from "../services/UserProfile.js" 14 | import { basicRuntime } from "./basicRuntime.js" 15 | import { AppLogger } from "./logger.js" 16 | 17 | export interface CTX { 18 | context: RequestContext 19 | } 20 | 21 | export type CTXMap = { 22 | allowAnonymous: RPCContextMap.Inverted<"userProfile", UserProfile, typeof NotLoggedInError> 23 | // TODO: not boolean but `string[]` 24 | requireRoles: RPCContextMap.Custom<"", never, typeof UnauthorizedError, Array> 25 | } 26 | 27 | // export const Auth0Config = Config.all({ 28 | // audience: Config.string("audience").pipe(Config.nested("auth0"), Config.withDefault("http://localhost:3610")), 29 | // issuer: Config.string("issuer").pipe( 30 | // Config.nested("auth0"), 31 | // Config.withDefault("https://effect-app-boilerplate-dev.eu.auth0.com") 32 | // ) 33 | // }) 34 | 35 | const RequestLayers = Layer.mergeAll(RequestCacheLayers) 36 | 37 | const middleware = makeMiddleware({ 38 | contextMap: null as unknown as CTXMap, 39 | // helper to deal with nested generic lmitations 40 | context: null as any as HttpServerRequest.HttpServerRequest, 41 | execute: Effect.gen(function*() { 42 | const fakeLogin = true 43 | // const authConfig = yield* Auth0Config 44 | const makeUserProfile = fakeLogin 45 | ? ((headers: HttpHeaders.Headers) => 46 | headers["x-user"] ? makeUserProfileFromUserHeader(headers["x-user"]) : Effect.succeed(undefined)) 47 | : ((headers: HttpHeaders.Headers) => 48 | headers["authorization"] 49 | ? makeUserProfileFromAuthorizationHeader(headers["authorization"]) 50 | : Effect.succeed(undefined)) 51 | 52 | return ( 53 | schema: T & S.Schema, 54 | handler: ( 55 | request: Req, 56 | headers: any 57 | ) => Effect.Effect, EffectRequest.Request.Error, R>, 58 | moduleName?: string 59 | ) => { 60 | const ContextLayer = (req: Req, headers: any) => 61 | Effect 62 | .gen(function*() { 63 | yield* Effect.annotateCurrentSpan("request.name", moduleName ? `${moduleName}.${req._tag}` : req._tag) 64 | 65 | const config = "config" in schema ? schema.config : undefined 66 | let ctx = Context.empty() 67 | 68 | // Check JWT 69 | // TODO 70 | // if (!fakeLogin && !request.allowAnonymous) { 71 | // yield* Effect.catchAll( 72 | // checkJWTI({ 73 | // ...authConfig, 74 | // issuer: authConfig.issuer + "/", 75 | // jwksUri: `${authConfig.issuer}/.well-known/jwks.json` 76 | // }), 77 | // (err) => 78 | // Effect.logError(err).pipe( 79 | // Effect.andThen(Effect.fail(new NotLoggedInError({ message: err.message }))) 80 | // ) 81 | // ) 82 | // } 83 | 84 | const r = yield* Effect.exit(makeUserProfile(headers)) 85 | if (!Exit.isSuccess(r)) { 86 | yield* AppLogger.logWarning("Parsing userInfo failed").pipe(Effect.annotateLogs("r", r)) 87 | } 88 | const userProfile = Option.fromNullable(Exit.isSuccess(r) ? r.value : undefined) 89 | if (Option.isSome(userProfile)) { 90 | ctx = ctx.pipe(Context.add(UserProfile, userProfile.value)) 91 | } else if (!config?.allowAnonymous) { 92 | return yield* new NotLoggedInError({ message: "no auth" }) 93 | } 94 | 95 | if (config?.requireRoles) { 96 | // TODO 97 | if ( 98 | !userProfile.value 99 | || !config.requireRoles.every((role: any) => userProfile.value!.roles.includes(role)) 100 | ) { 101 | return yield* new UnauthorizedError() 102 | } 103 | } 104 | 105 | return ctx as Context.Context> 106 | }) 107 | .pipe(Layer.effectContext, Layer.provide(RequestLayers)) 108 | return (req: Req, headers: any): Effect.Effect< 109 | Request.Request.Success, 110 | Request.Request.Error, 111 | | HttpServerRequest.HttpServerRequest 112 | | Exclude> 113 | > => 114 | Effect.gen(function*() { 115 | // console.log(yield* Effect.context()) 116 | // console.log("$test", yield* Test) 117 | // console.log("$test2", yield* FiberRef.get(Test2)) 118 | // TODO: somehow get the headers from Http and put them in the Rpc headers.. 119 | // perhaps do this elsewhere 120 | const httpReq = yield* HttpServerRequest.HttpServerRequest 121 | const abc = HttpHeaders.merge(httpReq.headers, headers) 122 | 123 | return yield* handler(req, abc).pipe( 124 | Effect.provide(ContextLayer(req, abc)) 125 | ) 126 | }) as any 127 | } 128 | }) 129 | }) 130 | 131 | const baseConfig = basicRuntime.runSync(BaseConfig) 132 | export const { Router, matchAll, matchFor } = makeRouter(middleware, baseConfig.env !== "prod") 133 | -------------------------------------------------------------------------------- /api/src/lib/middleware.ts: -------------------------------------------------------------------------------- 1 | import { HttpApp, HttpServerRequest } from "@effect/platform" 2 | import { Context, Effect } from "effect-app" 3 | import { HttpHeaders, HttpMiddleware, HttpServerResponse } from "effect-app/http" 4 | import { type ReadonlyRecord } from "effect/Record" 5 | import z from "zlib" 6 | 7 | export * from "@effect-app/infra/api/middlewares" 8 | 9 | // codegen:start {preset: barrel, includedpmidd: ./middleware/*.ts} 10 | export * from "./middleware/events.js" 11 | // codegen:end 12 | 13 | export const gzip = HttpMiddleware.make( 14 | (app) => 15 | Effect.gen(function*() { 16 | const r = yield* app 17 | const body = r.body 18 | if ( 19 | body._tag !== "Uint8Array" 20 | || body.contentLength === 0 21 | ) return r 22 | 23 | const req = yield* HttpServerRequest.HttpServerRequest 24 | if ( 25 | !req 26 | .headers["accept-encoding"] 27 | ?.split(",") 28 | .map((_) => _.trim()) 29 | .includes("gzip") 30 | ) return r 31 | 32 | // TODO: a stream may be better, for realtime compress? 33 | const buffer = yield* Effect.async((cb) => 34 | z.gzip(body.body, (err, r) => cb(err ? Effect.die(err) : Effect.succeed(r))) 35 | ) 36 | 37 | return HttpServerResponse.uint8Array( 38 | buffer, 39 | { 40 | cookies: r.cookies, 41 | status: r.status, 42 | statusText: r.statusText, 43 | contentType: body.contentType, 44 | headers: HttpHeaders.fromInput({ ...r.headers, "Content-Encoding": "gzip" }) 45 | } 46 | ) 47 | }) 48 | ) 49 | 50 | /** 51 | * a modified version that takes a function to determine if the origin is allowed. 52 | */ 53 | export const cors = (options?: { 54 | readonly allowedOrigins?: ReadonlyArray | ((origin: string) => boolean) 55 | readonly allowedMethods?: ReadonlyArray 56 | readonly allowedHeaders?: ReadonlyArray 57 | readonly exposedHeaders?: ReadonlyArray 58 | readonly maxAge?: number 59 | readonly credentials?: boolean 60 | }) => { 61 | const opts = { 62 | allowedOrigins: ["*"], 63 | allowedMethods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"], 64 | allowedHeaders: [], 65 | exposedHeaders: [], 66 | credentials: false, 67 | ...options 68 | } 69 | const makeAllowedOrigins = (allowedOrigins: ReadonlyArray | ((origin: string) => boolean)) => { 70 | if (typeof allowedOrigins === "function") { 71 | return (originHeader: string | undefined) => { 72 | if (originHeader && allowedOrigins(originHeader)) { 73 | return { 74 | "access-control-allow-origin": originHeader, 75 | vary: "Origin" 76 | } 77 | } 78 | return undefined 79 | } 80 | } 81 | 82 | const isAllowedOrigin = (origin: string) => allowedOrigins.includes(origin) 83 | 84 | return (originHeader: string | undefined): ReadonlyRecord | undefined => { 85 | if (!originHeader) return undefined 86 | if (allowedOrigins.length === 0) { 87 | return { "access-control-allow-origin": "*" } 88 | } 89 | 90 | if (allowedOrigins.length === 1) { 91 | return { 92 | "access-control-allow-origin": allowedOrigins[0]!, 93 | vary: "Origin" 94 | } 95 | } 96 | 97 | if (isAllowedOrigin(originHeader)) { 98 | return { 99 | "access-control-allow-origin": originHeader, 100 | vary: "Origin" 101 | } 102 | } 103 | 104 | return undefined 105 | } 106 | } 107 | 108 | const allowOrigin = makeAllowedOrigins(opts.allowedOrigins) 109 | 110 | const allowMethods = opts.allowedMethods.length > 0 111 | ? { "access-control-allow-methods": opts.allowedMethods.join(", ") } 112 | : undefined 113 | 114 | const allowCredentials = opts.credentials 115 | ? { "access-control-allow-credentials": "true" } 116 | : undefined 117 | 118 | const allowHeaders = ( 119 | accessControlRequestHeaders: string | undefined 120 | ): ReadonlyRecord | undefined => { 121 | if (opts.allowedHeaders.length === 0 && accessControlRequestHeaders) { 122 | return { 123 | vary: "Access-Control-Request-Headers", 124 | "access-control-allow-headers": accessControlRequestHeaders 125 | } 126 | } 127 | 128 | if (opts.allowedHeaders) { 129 | return { 130 | "access-control-allow-headers": opts.allowedHeaders.join(",") 131 | } 132 | } 133 | 134 | return undefined 135 | } 136 | 137 | const exposeHeaders = opts.exposedHeaders.length > 0 138 | ? { "access-control-expose-headers": opts.exposedHeaders.join(",") } 139 | : undefined 140 | 141 | const maxAge = opts.maxAge 142 | ? { "access-control-max-age": opts.maxAge.toString() } 143 | : undefined 144 | 145 | const headersFromRequest = (request: HttpServerRequest.HttpServerRequest) => { 146 | const origin = request.headers["origin"] 147 | return HttpHeaders.unsafeFromRecord({ 148 | ...allowOrigin(origin), 149 | ...allowCredentials, 150 | ...exposeHeaders 151 | }) 152 | } 153 | 154 | const headersFromRequestOptions = (request: HttpServerRequest.HttpServerRequest) => { 155 | const origin = request.headers["origin"]! 156 | const accessControlRequestHeaders = request.headers["access-control-request-headers"] 157 | return HttpHeaders.unsafeFromRecord({ 158 | ...allowOrigin(origin), 159 | ...allowCredentials, 160 | ...exposeHeaders, 161 | ...allowMethods, 162 | ...allowHeaders(accessControlRequestHeaders), 163 | ...maxAge 164 | }) 165 | } 166 | 167 | const preResponseHandler = ( 168 | request: HttpServerRequest.HttpServerRequest, 169 | response: HttpServerResponse.HttpServerResponse 170 | ) => Effect.succeed(HttpServerResponse.setHeaders(response, headersFromRequest(request))) 171 | 172 | return (httpApp: HttpApp.Default): HttpApp.Default => 173 | Effect.withFiberRuntime((fiber) => { 174 | const request = Context.unsafeGet(fiber.currentContext, HttpServerRequest.HttpServerRequest) 175 | if (request.method === "OPTIONS") { 176 | return Effect.succeed(HttpServerResponse.empty({ 177 | status: 204, 178 | headers: headersFromRequestOptions(request) 179 | })) 180 | } 181 | return Effect.zipRight(HttpApp.appendPreResponseHandler(preResponseHandler), httpApp) 182 | }) 183 | } 184 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@effect-app-boilerplate/api", 3 | "version": "0.0.1", 4 | "main": "./dist/main.js", 5 | "license": "MIT", 6 | "private": true, 7 | "type": "module", 8 | "scripts": { 9 | "circular": "pnpm circular:src && pnpm circular:dist", 10 | "circular:src": "madge --circular --ts-config ./tsconfig.json --extensions ts ./src", 11 | "circular:dist": "madge --circular --extensions js ./dist", 12 | "clean": "rm -rf dist test/dist", 13 | "clean-dist": "sh ../scripts/clean-dist.sh", 14 | "build": "pnpm clean-dist && NODE_OPTIONS=--max-old-space-size=8192 tsc --build", 15 | "watch": "pnpm build --watch", 16 | "watch2": "pnpm clean-dist && NODE_OPTIONS=--max-old-space-size=8192 tsc -w", 17 | "compile": "NODE_OPTIONS=--max-old-space-size=8192 tsc --noEmit", 18 | "lint": "NODE_OPTIONS=--max-old-space-size=8192 ESLINT_TS=1 eslint src test", 19 | "lint:watch": "ESLINT_TS=1 esw -w --changed --clear --ext ts,tsx src test", 20 | "autofix": "pnpm lint --fix", 21 | "test": "vitest", 22 | "test:run": "pnpm run test run --passWithNoTests", 23 | "testsuite": "pnpm circular && pnpm run test:run && pnpm lint", 24 | "dev": "NODE_ENV=development nodemon --signal SIGTERM --exec pnpm dev:tsx", 25 | "dev:compiled": "pnpm start:compiled", 26 | "dev:tsx": "pnpm start:tsx", 27 | "dev:vite": "vite-node --watch ./src/main.ts", 28 | "start": "pnpm start:compiled", 29 | "debug": "tsx ./src/main.ts --inspect", 30 | "start:compiled": "node -r source-map-support/register ./dist/main.js", 31 | "start:tsx": "tsx ./src/main.ts", 32 | "ncu": "ncu", 33 | "extract:i18n": "formatjs extract './**/*.ts' --ignore './**/*.d.ts' --format src/i18n/extraction-formatter.cjs --id-interpolation-pattern '[sha512:contenthash:base64:6]' --out-file src/i18n/extracted/en.json" 34 | }, 35 | "exports": { 36 | ".": { 37 | "import": { 38 | "types": "./dist/index.d.ts", 39 | "default": "./dist/index.js" 40 | }, 41 | "require": { 42 | "types": "./dist/index.d.ts", 43 | "default": "./_cjs/index.cjs" 44 | } 45 | }, 46 | "./*": { 47 | "import": { 48 | "types": "./dist/*.d.ts", 49 | "default": "./dist/*.js" 50 | }, 51 | "require": { 52 | "types": "./dist/*.d.ts", 53 | "default": "./_cjs/*.cjs" 54 | } 55 | } 56 | }, 57 | "imports": { 58 | "#api": { 59 | "import": { 60 | "types": "./dist/api.d.ts", 61 | "default": "./dist/api.js" 62 | }, 63 | "require": { 64 | "types": "./dist/api.d.ts", 65 | "default": "./_cjs/api.cjs" 66 | } 67 | }, 68 | "#config": { 69 | "import": { 70 | "types": "./dist/config.d.ts", 71 | "default": "./dist/config.js" 72 | }, 73 | "require": { 74 | "types": "./dist/config.d.ts", 75 | "default": "./_cjs/config.cjs" 76 | } 77 | }, 78 | "#lib/*": { 79 | "import": { 80 | "types": "./dist/lib/*.d.ts", 81 | "default": "./dist/lib/*.js" 82 | }, 83 | "require": { 84 | "types": "./dist/lib/*.d.ts", 85 | "default": "./_cjs/lib/*.cjs" 86 | } 87 | }, 88 | "#core/*": { 89 | "import": { 90 | "types": "./dist/core/*.d.ts", 91 | "default": "./dist/core/*.js" 92 | }, 93 | "require": { 94 | "types": "./dist/core/*.d.ts", 95 | "default": "./_cjs/core/*.cjs" 96 | } 97 | }, 98 | "#models": { 99 | "import": { 100 | "types": "./dist/models.d.ts", 101 | "default": "./dist/models.js" 102 | }, 103 | "require": { 104 | "types": "./dist/models.d.ts", 105 | "default": "./_cjs/models.cjs" 106 | } 107 | }, 108 | "#resources": { 109 | "import": { 110 | "types": "./dist/resources.d.ts", 111 | "default": "./dist/resources.js" 112 | }, 113 | "require": { 114 | "types": "./dist/resources.d.ts", 115 | "default": "./_cjs/resources.cjs" 116 | } 117 | }, 118 | "#models/*": { 119 | "import": { 120 | "types": "./dist/models/*.d.ts", 121 | "default": "./dist/models/*.js" 122 | }, 123 | "require": { 124 | "types": "./dist/models/*.d.ts", 125 | "default": "./_cjs/models/*.cjs" 126 | } 127 | }, 128 | "#resources/*": { 129 | "import": { 130 | "types": "./dist/resources/*.d.ts", 131 | "default": "./dist/resources/*.js" 132 | }, 133 | "require": { 134 | "types": "./dist/resources/*.d.ts", 135 | "default": "./_cjs/resources/*.cjs" 136 | } 137 | } 138 | }, 139 | "dependencies": { 140 | "@azure/arm-monitor": "^7.0.0", 141 | "@azure/cosmos": "^4.3.0", 142 | "@azure/service-bus": "^7.9.5", 143 | "@azure/storage-blob": "^12.27.0", 144 | "@effect-app/infra": "2.55.1", 145 | "effect-app": "^2.40.1", 146 | "@effect/platform": "^0.80.20", 147 | "@effect/opentelemetry": "^0.46.17", 148 | "@effect/platform-node": "0.77.10", 149 | "@effect/rpc": "^0.56.8", 150 | "@effect/rpc-http": "^0.52.4", 151 | "@effect/vitest": "^0.21.3", 152 | "@formatjs/cli": "^6.7.1", 153 | "@formatjs/intl": "3.1.6", 154 | "@mollie/api-client": "^4.3.1", 155 | "@opentelemetry/auto-instrumentations-node": "^0.58.1", 156 | "@opentelemetry/context-async-hooks": "^2.0.0", 157 | "@opentelemetry/sdk-node": "^0.200.0", 158 | "@sendgrid/mail": "^8.1.5", 159 | "@sentry/node": "9.14.0", 160 | "@sentry/opentelemetry": "9.14.0", 161 | "connect": "^3.7.0", 162 | "cors": "^2.8.5", 163 | "cross-fetch": "^4.1.0", 164 | "date-fns": "^4.1.0", 165 | "dotenv": "^16.5.0", 166 | "effect": "^3.14.20", 167 | "express": "^5.1.0", 168 | "express-compression": "^1.0.2", 169 | "express-oauth2-jwt-bearer": "^1.6.1", 170 | "fast-check": "^4.1.1", 171 | "jwks-rsa": "2.1.4", 172 | "jwt-decode": "^4.0.0", 173 | "object-hash": "^3.0.0", 174 | "papaparse": "^5.5.2", 175 | "redis": "^3.1.2", 176 | "redlock": "^5.0.0-beta.2", 177 | "redoc": "^2.5.0", 178 | "redoc-express": "^2.1.0", 179 | "source-map-support": "^0.5.21", 180 | "stopwatch-node": "^1.1.0", 181 | "swagger-ui-express": "^5.0.1", 182 | "tcp-port-used": "^1.0.2", 183 | "xlsx": "^0.18.5" 184 | }, 185 | "devDependencies": { 186 | "@types/body-parser": "^1.19.5", 187 | "@types/cors": "^2.8.17", 188 | "@types/express": "^5.0.1", 189 | "@types/redis": "^2.8.32", 190 | "@types/swagger-ui-express": "^4.1.8", 191 | "eslint-plugin-formatjs": "^5.3.1", 192 | "typescript": "~5.8.3" 193 | } 194 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Clean Dist", 6 | "type": "shell", 7 | "command": "pnpm clean-dist", 8 | "group": "build" 9 | }, 10 | { 11 | "label": "Watch API, Models, Resources", 12 | "type": "shell", 13 | "command": "pnpm watch-src", 14 | // "options": { 15 | // "cwd": "${workspaceRoot}/api/test", 16 | // }, 17 | "dependsOn": [ 18 | //"Scan Resources", 19 | "Clean Dist" 20 | ], 21 | "group": { 22 | "kind": "build", 23 | }, 24 | "isBackground": true, 25 | "presentation": { 26 | "group": "watch-build", 27 | }, 28 | "problemMatcher": [ 29 | { 30 | "base": "$tsc-watch", 31 | "fileLocation": [ 32 | "relative", 33 | "${workspaceRoot}", 34 | ], 35 | } 36 | ] 37 | }, 38 | { 39 | "label": "Watch Libs", 40 | "type": "shell", 41 | "command": "pnpm build-libs -w", 42 | // "options": { 43 | // "cwd": "${workspaceRoot}/api/test", 44 | // }, 45 | "dependsOn": [ 46 | //"Scan Resources", 47 | "Clean Dist" 48 | ], 49 | "group": { 50 | "kind": "build", 51 | }, 52 | "isBackground": true, 53 | "presentation": { 54 | "group": "watch-build", 55 | }, 56 | "problemMatcher": [ 57 | { 58 | "base": "$tsc-watch", 59 | "fileLocation": [ 60 | "relative", 61 | "${workspaceRoot}/libs", 62 | ], 63 | } 64 | ] 65 | }, 66 | { 67 | "label": "Watch E2E", 68 | "type": "shell", 69 | "command": "pnpm watch", 70 | "options": { 71 | "cwd": "${workspaceRoot}/e2e" 72 | }, 73 | "group": "build", 74 | "isBackground": true, 75 | "presentation": { 76 | "group": "watch-build" 77 | }, 78 | "problemMatcher": [ 79 | { 80 | "base": "$tsc-watch", 81 | "fileLocation": [ 82 | "relative", 83 | "${workspaceRoot}/e2e", 84 | ], 85 | } 86 | ] 87 | }, 88 | { 89 | "label": "Watch Frontend", 90 | "type": "shell", 91 | "command": "pnpm watch", 92 | "options": { 93 | "cwd": "${workspaceRoot}/frontend" 94 | }, 95 | "dependsOn": [ 96 | "Watch API, Models, Resources" 97 | ], 98 | "group": { 99 | "kind": "build", 100 | "isDefault": true 101 | }, 102 | "isBackground": true, 103 | "presentation": { 104 | "group": "watch-build" 105 | }, 106 | "problemMatcher": [ 107 | { 108 | "base": "$tsc-watch", 109 | "fileLocation": [ 110 | "relative", 111 | "${workspaceRoot}/frontend", 112 | ], 113 | } 114 | ] 115 | }, 116 | { 117 | "label": "Run API", 118 | "type": "shell", 119 | "command": "pnpm dev", 120 | "options": { 121 | "cwd": "${workspaceRoot}/api" 122 | }, 123 | "isBackground": true, 124 | // FAKE... but works.. 125 | "problemMatcher": { 126 | "severity": "warning", 127 | "pattern": [ 128 | { 129 | "regexp": "^\\s+(.*)\\((\\d+),(\\d+)\\):\\s+(.*)$", 130 | "file": 1, 131 | "line": 2, 132 | "column": 3, 133 | "message": 4 134 | } 135 | ], 136 | "background": { 137 | "beginsPattern": "starting", 138 | "endsPattern": "Running on", 139 | } 140 | }, 141 | "presentation": { 142 | "group": "run" 143 | } 144 | }, 145 | { 146 | "label": "Run API Debug", 147 | "type": "shell", 148 | "command": "pnpm dev:compiled", 149 | "options": { 150 | "cwd": "${workspaceRoot}/api" 151 | }, 152 | "isBackground": true, 153 | // FAKE... but works.. 154 | "problemMatcher": { 155 | "severity": "warning", 156 | "pattern": [ 157 | { 158 | "regexp": "^\\s+(.*)\\((\\d+),(\\d+)\\):\\s+(.*)$", 159 | "file": 1, 160 | "line": 2, 161 | "column": 3, 162 | "message": 4 163 | } 164 | ], 165 | "background": { 166 | "beginsPattern": "starting", 167 | "endsPattern": "Running on", 168 | } 169 | }, 170 | "presentation": { 171 | "group": "run" 172 | } 173 | }, 174 | { 175 | "label": "Run UI", 176 | "type": "shell", 177 | "command": "pnpm dev", 178 | "options": { 179 | "cwd": "${workspaceRoot}/frontend" 180 | }, 181 | "dependsOrder": "sequence", 182 | "dependsOn": [ 183 | // "Clean", 184 | // "API Watch", 185 | // "UI Watch", 186 | "Run API", 187 | ], 188 | "isBackground": true, 189 | // FAKE... 190 | "problemMatcher": { 191 | "severity": "warning", 192 | "pattern": [ 193 | { 194 | "regexp": "^\\s+(.*)\\((\\d+),(\\d+)\\):\\s+(.*)$", 195 | "file": 1, 196 | "line": 2, 197 | "column": 3, 198 | "message": 4 199 | } 200 | ], 201 | "background": { 202 | "activeOnStart": true, 203 | "beginsPattern": "Nuxi", 204 | "endsPattern": "Vite client warmed up in", 205 | } 206 | }, 207 | "presentation": { 208 | "group": "run" 209 | } 210 | }, 211 | { 212 | "label": "API-E2E", 213 | "type": "shell", 214 | "command": "pnpm run test --watch", 215 | "options": { 216 | "cwd": "${workspaceRoot}/api/test" 217 | }, 218 | "group": "test", 219 | "isBackground": true, 220 | // FAKE... but works.. 221 | "problemMatcher": { 222 | "severity": "warning", 223 | "pattern": [ 224 | { 225 | "regexp": "^\\s+(.*)\\((\\d+),(\\d+)\\):\\s+(.*)$", 226 | "file": 1, 227 | "line": 2, 228 | "column": 3, 229 | "message": 4 230 | } 231 | ], 232 | "background": { 233 | "beginsPattern": "starting", 234 | "endsPattern": "Running on", 235 | } 236 | }, 237 | "presentation": { 238 | "group": "test" 239 | } 240 | }, 241 | { 242 | "label": "E2E", 243 | "type": "shell", 244 | "command": "pnpm run test", 245 | "options": { 246 | "cwd": "${workspaceRoot}/e2e" 247 | }, 248 | "group": "test", 249 | "isBackground": true, 250 | // FAKE... but works.. 251 | "problemMatcher": { 252 | "severity": "warning", 253 | "pattern": [ 254 | { 255 | "regexp": "^\\s+(.*)\\((\\d+),(\\d+)\\):\\s+(.*)$", 256 | "file": 1, 257 | "line": 2, 258 | "column": 3, 259 | "message": 4 260 | } 261 | ], 262 | "background": { 263 | "beginsPattern": "starting", 264 | "endsPattern": "Running on", 265 | } 266 | }, 267 | "presentation": { 268 | "group": "run" 269 | } 270 | }, 271 | { 272 | "label": "UI Build Prod", 273 | "type": "shell", 274 | "command": "pnpm build", 275 | "options": { 276 | "cwd": "${workspaceRoot}/frontend" 277 | }, 278 | "group": "build", 279 | "dependsOrder": "sequence", 280 | "dependsOn": [ 281 | "Types Build", 282 | "Client Build" 283 | ], 284 | "problemMatcher": [] 285 | }, 286 | { 287 | "label": "Develop", 288 | "type": "shell", 289 | "dependsOn": [ 290 | "Watch Frontend", 291 | "Run UI", 292 | "Run API" 293 | ], 294 | "isBackground": true, 295 | "dependsOrder": "parallel", 296 | "presentation": { 297 | "group": "run", 298 | "reveal": "never" 299 | }, 300 | "problemMatcher":[] 301 | } 302 | ] 303 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@effect-app-boilerplate/root", 3 | "private": true, 4 | "type": "module", 5 | "pnpm": { 6 | "patchedDependencies": { 7 | "eslint-plugin-codegen@0.17.0": "patches/eslint-plugin-codegen@0.17.0.patch", 8 | "@hebilicious/vue-query-nuxt@0.3.0": "patches/@hebilicious__vue-query-nuxt@0.3.0.patch", 9 | "typescript": "patches/typescript.patch", 10 | "@effect/platform": "patches/@effect__platform.patch", 11 | "effect": "patches/effect.patch", 12 | "ts-plugin-sort-import-suggestions": "patches/ts-plugin-sort-import-suggestions.patch", 13 | "@tanstack/query-core": "patches/@tanstack__query-core.patch", 14 | "@sentry/core": "patches/@sentry__core.patch", 15 | "@sentry/node": "patches/@sentry__node.patch", 16 | "@sentry/opentelemetry": "patches/@sentry__opentelemetry.patch" 17 | } 18 | }, 19 | "engines": { 20 | "pnpm": ">= 10.2.1" 21 | }, 22 | "scripts": { 23 | "all": "pnpm -r --filter \"!./play\" --filter \"!./libs/**/*\"", 24 | "preinstall": "npx only-allow pnpm", 25 | "clean": "pnpm all clean", 26 | "clean-dist": "pnpm all clean-dist", 27 | "autofix": "NODE_OPTIONS=--max-old-space-size=8192 pnpm lint-api --fix && pnpm lint-frontend --fix && pnpm lint-e2e --fix", 28 | "lint": "pnpm lint-api && pnpm lint-frontend && pnpm lint-e2e", 29 | "circular:dist": "pnpm all circular:dist", 30 | "start": "concurrently -k -p \"[{name}]\" -n \"API,Frontend\" -c \"cyan.bold,green.bold,blue.bold\" \"pnpm start:api\" \"pnpm start:frontend\"", 31 | "start:api": "cd api && pnpm start", 32 | "start:frontend": "cd frontend && pnpm start", 33 | "dev": "concurrently -k -p \"[{name}]\" -n \"Watch,API,Frontend\" -c \"yellow.bold,cyan.bold,green.bold\" \"pnpm watch\" \"pnpm dev:api\" \"pnpm dev:frontend\"", 34 | "dev:api": "cd api && pnpm dev", 35 | "dev:frontend": "cd frontend && pnpm dev", 36 | "build-apps": "cd api && pnpm build && cd ../frontend && pnpm build", 37 | "test": "pnpm all test", 38 | "testsuite": "pnpm all testsuite", 39 | "up-all": "pnpm --recursive update", 40 | "u": "pnpm run update && pnpm i && pnpm dedupe", 41 | "update": "ncu -u && pnpm -r ncu -u", 42 | "ncu": "ncu", 43 | "build-libs": "cd libs && pnpm build && cd ../.. && pnpm build", 44 | "lint:watch": "ESLINT_TS=1 esw -w --changed --clear --ext ts,tsx", 45 | "apps": "pnpm all --workspace-concurrency 1", 46 | "api": "pnpm all --workspace-concurrency 1 --filter \"./api\"", 47 | "lint-api": "cd api && pnpm lint", 48 | "lint-e2e": "cd e2e && pnpm lint", 49 | "lint-frontend": "cd frontend && pnpm lint", 50 | "build:tsc": "NODE_OPTIONS=--max-old-space-size=8192 effect-app-cli index-multi tsc --build", 51 | "build": "pnpm build:tsc ./tsconfig.all.json && cd frontend && pnpm compile", 52 | "watch": "pnpm build:tsc ./tsconfig.all.json --watch", 53 | "watch-src": "pnpm build:tsc ./tsconfig.src.json --watch", 54 | "build-prod": "pnpm packages build && pnpm build-frontend-prod", 55 | "build-frontend-prod": "cd frontend && pnpm build", 56 | "rbuild": "pnpm clean && pnpm build", 57 | "nnm": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' + && pnpm i" 58 | }, 59 | "resolutions": { 60 | "@opentelemetry/api": "$@opentelemetry/api", 61 | "@opentelemetry/exporter-metrics-otlp-http": "$@opentelemetry/exporter-metrics-otlp-http", 62 | "@opentelemetry/exporter-trace-otlp-http": "$@opentelemetry/exporter-trace-otlp-http", 63 | "@opentelemetry/resources": "$@opentelemetry/resources", 64 | "@opentelemetry/sdk-logs": "$@opentelemetry/sdk-logs", 65 | "@opentelemetry/sdk-metrics": "$@opentelemetry/sdk-metrics", 66 | "@opentelemetry/sdk-trace-base": "$@opentelemetry/sdk-trace-base", 67 | "@opentelemetry/sdk-trace-node": "$@opentelemetry/sdk-trace-node", 68 | "@opentelemetry/sdk-trace-web": "$@opentelemetry/sdk-trace-web", 69 | "@opentelemetry/semantic-conventions": "$@opentelemetry/semantic-conventions", 70 | "date-fns": "$date-fns", 71 | "effect": "$effect", 72 | "@effect/experimental": "$@effect/experimental", 73 | "@effect/opentelemetry": "$@effect/opentelemetry", 74 | "@effect/platform": "$@effect/platform", 75 | "@effect/platform-node": "$@effect/platform-node", 76 | "fast-check": "$fast-check", 77 | "vue": "$vue" 78 | }, 79 | "dependencies": { 80 | "date-fns": "^4.1.0", 81 | "effect": "3.14.20", 82 | "@effect/experimental": "0.44.20", 83 | "@effect/opentelemetry": "0.46.17", 84 | "@effect/platform": "0.80.20", 85 | "@effect/platform-node": "0.77.10", 86 | "@opentelemetry/api": "^1.9", 87 | "@opentelemetry/exporter-metrics-otlp-http": "^0.200.0", 88 | "@opentelemetry/exporter-trace-otlp-http": "^0.200.0", 89 | "@opentelemetry/resources": "^2.0.0", 90 | "@opentelemetry/sdk-logs": "^0.200.0", 91 | "@opentelemetry/sdk-metrics": "^2.0.0", 92 | "@opentelemetry/sdk-trace-base": "^2.0.0", 93 | "@opentelemetry/sdk-trace-node": "^2.0.0", 94 | "@opentelemetry/sdk-trace-web": "^2.0.0", 95 | "@opentelemetry/semantic-conventions": "^1.33.0", 96 | "cross-env": "^7.0.3", 97 | "fast-check": "^4.1.1", 98 | "package-up": "^5.0.0", 99 | "patch-package": "^8.0.0", 100 | "vite-node": "^3.1.3", 101 | "vite-tsconfig-paths": "^5.1.4", 102 | "vue": "^3.5.13" 103 | }, 104 | "devDependencies": { 105 | "@ben_12/eslint-plugin-dprint": "^1.2.1", 106 | "@dprint/typescript": "^0.95.0", 107 | "@effect-app/cli": "^1.19.0", 108 | "@effect-app/eslint-codegen-model": "^1.37.0", 109 | "@effect/language-service": "^0.9.2", 110 | "@phaphoso/eslint-plugin-dprint": "^0.5.2", 111 | "@tsconfig/strictest": "^2.0.5", 112 | "@types/lodash": "^4.17.16", 113 | "@types/node": "~22.15.16", 114 | "@typescript-eslint/eslint-plugin": "^8.32.0", 115 | "@typescript-eslint/parser": "^8.32.0", 116 | "@typescript-eslint/scope-manager": "^8.32.0", 117 | "concurrently": "^9.1.2", 118 | "dprint": "^0.49.1", 119 | "effect-app": "^2.40.1", 120 | "enhanced-resolve": "^5.18.1", 121 | "eslint": "^9.26.0", 122 | "eslint-import-resolver-typescript": "^4.3.4", 123 | "eslint-import-resolver-webpack": "^0.13.10", 124 | "eslint-plugin-codegen": "^0.17.0", 125 | "eslint-plugin-import": "^2.31.0", 126 | "eslint-plugin-prettier-vue": "^5.0.0", 127 | "eslint-plugin-simple-import-sort": "^12.1.1", 128 | "eslint-plugin-sort-destructure-keys": "^2.0.0", 129 | "eslint-plugin-unused-imports": "^4.1.4", 130 | "eslint-watch": "^8.0.0", 131 | "@vue/eslint-config-prettier": "^10.2.0", 132 | "@vue/eslint-config-typescript": "^14.5.0", 133 | "madge": "^8.0.0", 134 | "nodemon": "^3.1.10", 135 | "npm-check-updates": "^18.0.1", 136 | "prebuild-install": "^7.1.3", 137 | "ts-plugin-sort-import-suggestions": "^1.0.4", 138 | "ts-toolbelt": "^9.6.0", 139 | "tsc-watch": "^6.2.1", 140 | "tsx": "^4.19.4", 141 | "typescript": "~5.8.3", 142 | "unplugin-auto-import": "^19.2.0", 143 | "vite": "^6.3.5", 144 | "vitest": "^3.1.3" 145 | } 146 | } -------------------------------------------------------------------------------- /api/src/lib/observability.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import * as Metrics from "@effect/opentelemetry/Metrics" 3 | import * as Resource from "@effect/opentelemetry/Resource" 4 | import * as Tracer from "@effect/opentelemetry/Tracer" 5 | import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node" 6 | import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks" 7 | import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http" 8 | import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http" 9 | import { ConsoleMetricExporter, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics" 10 | import opentelemetry from "@opentelemetry/sdk-node" 11 | import { BatchSpanProcessor, ConsoleSpanExporter, NoopSpanProcessor } from "@opentelemetry/sdk-trace-node" 12 | import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions" 13 | import * as Sentry from "@sentry/node" 14 | import { SentryPropagator, SentrySampler, SentrySpanProcessor, setupEventContextTrace, wrapContextManagerClass } from "@sentry/opentelemetry" 15 | import { Context, Effect, Layer, Redacted } from "effect-app" 16 | import { dropUndefinedT } from "effect-app/utils" 17 | import fs from "fs" 18 | import tcpPortUsed from "tcp-port-used" 19 | import { BaseConfig } from "../config.js" 20 | import { basicRuntime } from "./basicRuntime.js" 21 | 22 | const localConsole = false 23 | 24 | const appConfig = basicRuntime.runSync(BaseConfig) 25 | const isRemote = appConfig.env !== "local-dev" 26 | 27 | const ResourceLive = Resource.layer({ 28 | serviceName: appConfig.serviceName, 29 | serviceVersion: appConfig.apiVersion, 30 | attributes: { 31 | [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: appConfig.env 32 | } 33 | }) 34 | 35 | const checkTelemetryExporterRunning = Effect.promise(() => tcpPortUsed.check(4318, "localhost")).pipe( 36 | Effect.tap((isTelemetryExporterRunning) => 37 | Effect.sync(() => { 38 | if (isTelemetryExporterRunning) { 39 | fs.writeFileSync( 40 | "../.telemetry-exporter-running", 41 | isTelemetryExporterRunning.toString() 42 | ) 43 | } else { 44 | if (fs.existsSync("../.telemetry-exporter-running")) fs.unlinkSync("../.telemetry-exporter-running") 45 | } 46 | }) 47 | ), 48 | Effect.cached, 49 | Effect.runSync 50 | ) 51 | 52 | const makeMetricsReader = Effect.gen(function*() { 53 | const isTelemetryExporterRunning = !isRemote 54 | && (yield* checkTelemetryExporterRunning) 55 | 56 | const makeMetricReader = !isTelemetryExporterRunning 57 | ? isRemote 58 | ? undefined 59 | : localConsole 60 | ? () => 61 | [ 62 | new PeriodicExportingMetricReader({ 63 | exporter: new ConsoleMetricExporter() 64 | }) 65 | ] as const 66 | : undefined 67 | : () => 68 | [ 69 | new PeriodicExportingMetricReader({ 70 | exporter: new OTLPMetricExporter({ 71 | url: "http://127.0.0.1:9090/api/v1/otlp/v1/metrics" 72 | }) 73 | }) 74 | ] as const 75 | 76 | return { makeMetricReader } 77 | }) 78 | 79 | export class MetricsReader extends Context.TagMakeId("MetricsReader", makeMetricsReader)() { 80 | static readonly Live = this.toLayer() 81 | } 82 | 83 | const filteredOps = ["Import.AllOperations", "Operations.FindOperation"] 84 | const filteredPaths = ["/.well-known/local/server-health", ...filteredOps.map((op) => `/${op}`)] 85 | const filteredMethods = ["OPTIONS"] 86 | const filterAttrs = { 87 | "request.name": filteredOps, 88 | "http.request.path": filteredPaths, 89 | "http.target": filteredPaths, 90 | "http.url": filteredPaths, 91 | "http.route": filteredPaths, 92 | "url.path": filteredPaths, 93 | "http.method": filteredMethods, 94 | "http.request.method": filteredMethods 95 | } 96 | const filteredEntries = Object.entries(filterAttrs) 97 | 98 | const setupSentry = (options?: Sentry.NodeOptions) => { 99 | Sentry.init({ 100 | ...dropUndefinedT({ 101 | // otherwise sentry will set it up and override ours 102 | skipOpenTelemetrySetup: true, 103 | dsn: Redacted.value(appConfig.sentry.dsn), 104 | environment: appConfig.env, 105 | enabled: isRemote, 106 | release: appConfig.apiVersion, 107 | normalizeDepth: 5, // default 3 108 | // Set tracesSampleRate to 1.0 to capture 100% 109 | // of transactions for performance monitoring. 110 | // We recommend adjusting this value in production 111 | tracesSampleRate: 1.0, 112 | ...options 113 | }), 114 | beforeSendTransaction(event) { 115 | const otelAttrs = (event.contexts?.["otel"]?.["attributes"] as any) ?? {} 116 | const traceData = (event.contexts?.["trace"]?.["data"] as any) ?? {} 117 | if ( 118 | filteredEntries.some(([k, vs]) => 119 | vs.some((v) => 120 | otelAttrs[k] === v 121 | || traceData[k] === v 122 | || event.spans?.some((s) => s.data?.[k] === v) 123 | ) 124 | ) 125 | ) { 126 | return null 127 | } 128 | return event 129 | } 130 | }) 131 | } 132 | 133 | const ConfigLive = Effect 134 | .gen(function*() { 135 | const isTelemetryExporterRunning = !isRemote 136 | && (yield* checkTelemetryExporterRunning) 137 | 138 | const { makeMetricReader } = yield* MetricsReader 139 | 140 | const mr = makeMetricReader?.() 141 | 142 | let props: Partial = dropUndefinedT({ 143 | metricReader: mr ? mr[0] : undefined, 144 | spanProcessors: isTelemetryExporterRunning || localConsole 145 | ? [ 146 | new BatchSpanProcessor( 147 | isTelemetryExporterRunning 148 | ? new OTLPTraceExporter({ 149 | url: "http://localhost:4318/v1/traces" 150 | }) 151 | : new ConsoleSpanExporter() 152 | ) 153 | ] 154 | : [new NoopSpanProcessor()] 155 | }) 156 | 157 | setupSentry(dropUndefinedT({})) 158 | 159 | const resource = yield* Resource.Resource 160 | 161 | if (isRemote) { 162 | const client = Sentry.getClient()! 163 | setupEventContextTrace(client) 164 | 165 | // You can wrap whatever local storage context manager you want to use 166 | const SentryContextManager = wrapContextManagerClass( 167 | AsyncLocalStorageContextManager 168 | ) 169 | 170 | props = { 171 | // Sentry config 172 | spanProcessors: [ 173 | new SentrySpanProcessor() 174 | ], 175 | textMapPropagator: new SentryPropagator(), 176 | contextManager: new SentryContextManager(), 177 | sampler: new SentrySampler(client) 178 | } 179 | } 180 | 181 | props = { 182 | instrumentations: [ 183 | getNodeAutoInstrumentations({ 184 | "@opentelemetry/instrumentation-http": { 185 | // effect http server already does this 186 | disableIncomingRequestInstrumentation: true 187 | } 188 | }) 189 | ], 190 | 191 | resource, 192 | 193 | ...props 194 | } 195 | const sdk = new opentelemetry.NodeSDK(props) 196 | 197 | // Ensure OpenTelemetry Context & Sentry Hub/Scope is synced 198 | // seems to be always set by Sentry 8.0.0 anyway 199 | // setOpenTelemetryContextAsyncContextStrategy() 200 | 201 | sdk.start() 202 | yield* Effect.addFinalizer(() => Effect.promise(() => sdk.shutdown())) 203 | }) 204 | .pipe(Layer.scopedDiscard, Layer.provide(Layer.mergeAll(MetricsReader.Live, ResourceLive))) 205 | 206 | const MetricsLive = MetricsReader 207 | .use(({ makeMetricReader }) => makeMetricReader ? Metrics.layer(makeMetricReader) : Layer.empty) 208 | .pipe( 209 | Layer.unwrapEffect, 210 | Layer.provide(Layer.mergeAll(ResourceLive, MetricsReader.Live)) 211 | ) 212 | const NodeSdkLive = Layer.mergeAll(ConfigLive, MetricsLive) 213 | export const TracingLive = Layer.mergeAll( 214 | NodeSdkLive, 215 | Tracer.layerGlobal.pipe(Layer.provide(ResourceLive)) 216 | ) 217 | -------------------------------------------------------------------------------- /eslint.base.config.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | // @ts-nocheck 3 | import { FlatCompat } from "@eslint/eslintrc" 4 | import js from "@eslint/js" 5 | import path from "node:path" 6 | import { fileURLToPath } from "node:url" 7 | 8 | import tsParser from "@typescript-eslint/parser" 9 | 10 | import codegen from "eslint-plugin-codegen" 11 | import _import from "eslint-plugin-import" 12 | import sortDestructureKeys from "eslint-plugin-sort-destructure-keys" 13 | import unusedImports from "eslint-plugin-unused-imports" 14 | 15 | 16 | import eslint from '@eslint/js'; 17 | import tseslint from 'typescript-eslint'; 18 | import dprint from "@ben_12/eslint-plugin-dprint"; 19 | 20 | 21 | const __filename = fileURLToPath(import.meta.url) 22 | const __dirname = path.dirname(__filename) 23 | 24 | const compat = new FlatCompat({ 25 | baseDirectory: __dirname, 26 | recommendedConfig: js.configs.recommended, 27 | allConfig: js.configs.all 28 | }) 29 | 30 | /** 31 | * @param {string} dirName 32 | * @param {boolean} [forceTS=false] 33 | * @returns {import("eslint").Linter.FlatConfig[]} 34 | */ 35 | export function baseConfig(dirName, forceTS = false, project = undefined) { 36 | // eslint-disable-next-line no-undef 37 | const enableTS = !!dirName && (forceTS || process.env["ESLINT_TS"]) 38 | return [ 39 | { 40 | ignores: [ 41 | "**/*.js", 42 | // "**/*.mjs", 43 | // "**/*.cjs", 44 | "**/*.jsx", 45 | "**/*.d.ts", 46 | "**/node_modules/**", 47 | "vitest.config.ts", 48 | "vitest.config.test.ts", 49 | "vite.config.ts", 50 | "eslint.*.mjs", 51 | ] 52 | }, 53 | js.configs.recommended, 54 | ...tseslint.config( 55 | eslint.configs.recommended, 56 | tseslint.configs.recommended, 57 | ), 58 | ...(enableTS ? tseslint.configs.recommendedTypeChecked : []), 59 | { 60 | // otherwise this config object doesn't apply inside vue files 61 | // I mean the rules are not applied, the plugins are not loaded 62 | // files: ["**/*.ts", "**/*.tsx"], 63 | name: "base", 64 | languageOptions: { 65 | parser: tsParser, 66 | parserOptions: { 67 | extraFileExtensions: [".vue"], // should be the same as vue config for perfomance reasons (https://typescript-eslint.io/troubleshooting/typed-linting/performance#project-service-issues) 68 | ...(enableTS 69 | && { 70 | tsconfigRootDir: dirName, 71 | projectService: true 72 | }) 73 | } 74 | }, 75 | settings: { 76 | "import/parsers": { 77 | "@typescript-eslint/parser": [".ts", ".tsx"] 78 | }, 79 | "import/resolver": { 80 | typescript: { 81 | alwaysTryTypes: true 82 | } // this loads /tsconfig.json to eslint 83 | } 84 | }, 85 | linterOptions: { 86 | reportUnusedDisableDirectives: "off" 87 | }, 88 | plugins: { 89 | import: _import, 90 | "sort-destructure-keys": sortDestructureKeys, 91 | "unused-imports": unusedImports, 92 | codegen 93 | }, 94 | rules: { 95 | "no-unexpected-multiline": "off", 96 | "no-restricted-imports": ["error", { 97 | "paths": [ 98 | { 99 | name: ".", 100 | "message": 101 | "Please import from the specific file instead. Imports from index in the same directory are almost always wrong (circular)." 102 | }, 103 | { 104 | name: "./", 105 | "message": 106 | "Please import from the specific file instead. Imports from index in the same directory are almost always wrong (circular)." 107 | }, 108 | { 109 | name: "./index", 110 | "message": 111 | "Please import from the specific file instead. Imports from index in the same directory are almost always wrong (circular)." 112 | } 113 | ] 114 | }], 115 | "@typescript-eslint/no-namespace": "off", // We like namespaces, where ES modules cannot compete (augmenting existing types) 116 | "no-unused-vars": "off", 117 | "@typescript-eslint/no-unused-vars": "off", 118 | "unused-imports/no-unused-imports": "error", 119 | "unused-imports/no-unused-vars": [ 120 | "warn", 121 | { 122 | "vars": "all", 123 | "varsIgnorePattern": "^_", 124 | "args": "after-used", 125 | "argsIgnorePattern": "^_", 126 | "ignoreRestSiblings": true 127 | } 128 | ], 129 | "@typescript-eslint/no-use-before-define": ["warn", { functions: false, classes: true, variables: true }], // functions may depend on classes or variables defined later 130 | "@typescript-eslint/explicit-function-return-type": "off", 131 | "@typescript-eslint/interface-name-prefix": "off", 132 | "@typescript-eslint/no-empty-object-type": "off", 133 | "sort-destructure-keys/sort-destructure-keys": "error", // Mainly to sort render props 134 | "require-yield": "off", // we want to be able to use e.g Effect.gen without having to worry about lint. 135 | "sort-imports": "off", 136 | "import/first": "error", 137 | "import/newline-after-import": "error", 138 | "import/no-duplicates": ["error", {"prefer-inline": true}], 139 | "import/no-unresolved": "off", // eslint don't understand some imports very well 140 | "import/order": "off", 141 | "@typescript-eslint/consistent-type-imports": ["error", { fixStyle: 'inline-type-imports' }], 142 | 143 | "object-shorthand": "error", 144 | ...(enableTS 145 | && { 146 | "@typescript-eslint/restrict-template-expressions": "warn", 147 | "@typescript-eslint/restrict-plus-operands": "off", 148 | "@typescript-eslint/no-unsafe-assignment": "warn", 149 | "@typescript-eslint/no-unsafe-call": "warn", 150 | "@typescript-eslint/no-unsafe-return": "warn", 151 | "@typescript-eslint/no-unsafe-argument": "warn", 152 | "@typescript-eslint/no-unsafe-member-access": "warn", 153 | "@typescript-eslint/no-misused-promises": "warn", 154 | "@typescript-eslint/unbound-method": "warn", 155 | "@typescript-eslint/only-throw-error": "off", 156 | "@typescript-eslint/no-base-to-string": "warn", 157 | "@typescript-eslint/no-floating-promises": "warn", 158 | }) 159 | } 160 | } 161 | ] 162 | } 163 | 164 | /** 165 | * @param {string} dirName 166 | * @param {boolean} [forceTS=false] 167 | * @returns {import("eslint").Linter.FlatConfig[]} 168 | */ 169 | export function augmentedConfig(dirName, forceTS = false, project = undefined) { 170 | return [ 171 | ...baseConfig(dirName, forceTS, project), 172 | { 173 | name: "augmented", 174 | plugins: { 175 | "@ben_12/dprint": dprint 176 | }, 177 | rules: { 178 | ...dprint.configs["typescript-recommended"].rules, 179 | "@ben_12/dprint/typescript": [ 180 | "error", 181 | { 182 | // Use dprint JSON configuration file (default: "dprint.json") 183 | // It may be created using `dprint init` command 184 | // See also https://dprint.dev/config/ 185 | //configFile: "dprint.json", 186 | // The TypeScript configuration of dprint 187 | // See also https://dprint.dev/plugins/typescript/config/ 188 | config: { 189 | // The TypeScript configuration of dprint 190 | // See also https://dprint.dev/plugins/typescript/config/, 191 | "indentWidth": 2, 192 | "semiColons": "asi", 193 | "quoteStyle": "alwaysDouble", 194 | "trailingCommas": "never", 195 | "arrowFunction.useParentheses": "force", 196 | "memberExpression.linePerExpression": true, 197 | "binaryExpression.linePerExpression": true, 198 | "importDeclaration.forceSingleLine": true, 199 | "exportDeclaration.forceSingleLine": true 200 | } 201 | }, 202 | ], 203 | "codegen/codegen": [ 204 | "error", 205 | { 206 | presets: "@effect-app/eslint-codegen-model/dist/presets/index.js" 207 | } 208 | ] 209 | } 210 | } 211 | ] 212 | } -------------------------------------------------------------------------------- /patches/typescript.patch: -------------------------------------------------------------------------------- 1 | diff --git a/lib/_tsc.js b/lib/_tsc.js 2 | index 586d0514b6842000c0869c18f737802b85bef13d..7346151ed8f828f4f97023b93ed4bfb5c2e8ef90 100644 3 | --- a/lib/_tsc.js 4 | +++ b/lib/_tsc.js 5 | @@ -12630,7 +12630,7 @@ function isInternalDeclaration(node, sourceFile) { 6 | // src/compiler/utilities.ts 7 | var resolvingEmptyArray = []; 8 | var externalHelpersModuleNameText = "tslib"; 9 | -var defaultMaximumTruncationLength = 160; 10 | +var defaultMaximumTruncationLength = 3200; 11 | var noTruncationMaximumTruncationLength = 1e6; 12 | function getDeclarationOfKind(symbol, kind) { 13 | const declarations = symbol.declarations; 14 | diff --git a/lib/typescript.js b/lib/typescript.js 15 | index dc0fe9a56bb4d9b08efe8b5948a60a74a4e9997b..21f310639c1efc4f59ed7b7872db8b2feef90694 100644 16 | --- a/lib/typescript.js 17 | +++ b/lib/typescript.js 18 | @@ -16213,7 +16213,7 @@ function isInternalDeclaration(node, sourceFile) { 19 | // src/compiler/utilities.ts 20 | var resolvingEmptyArray = []; 21 | var externalHelpersModuleNameText = "tslib"; 22 | -var defaultMaximumTruncationLength = 160; 23 | +var defaultMaximumTruncationLength = 3200; 24 | var noTruncationMaximumTruncationLength = 1e6; 25 | function getDeclarationOfKind(symbol, kind) { 26 | const declarations = symbol.declarations; 27 | @@ -158232,9 +158232,128 @@ function getFixInfos(context, errorCode, pos, useAutoImportProvider) { 28 | const packageJsonImportFilter = createPackageJsonImportFilter(context.sourceFile, context.preferences, context.host); 29 | return info && sortFixInfo(info, context.sourceFile, context.program, packageJsonImportFilter, context.host, context.preferences); 30 | } 31 | +const autoImportOrderModule = (() => { 32 | + 33 | + const fs = require("fs"); 34 | + const path = require("path"); 35 | + 36 | + let pluginConfig = null; 37 | + let moveUpRegexes = []; 38 | + let moveDownRegexes = []; 39 | + const overrides = new Map() 40 | + 41 | + function getProjectRoot(startDir = __dirname) { 42 | + let currentDir = startDir; 43 | + while (!fs.existsSync(path.join(currentDir, "package.json"))) { 44 | + const parentDir = path.dirname(currentDir); 45 | + if (parentDir === currentDir) { 46 | + return null; // Unable to find the project root (package.json not found) 47 | + } 48 | + currentDir = parentDir; 49 | + } 50 | + return currentDir.split(path.sep + "node_modules")[0]; 51 | + } 52 | + 53 | + function log(message, overwrite = false) { 54 | + const _message = (typeof message === "string" ? message : JSON.stringify(message, null, 2)) + "\n" 55 | + if(overwrite) { 56 | + fs.writeFileSync(path.resolve(__dirname, './import-order-plugin.log'), _message, 'utf8'); 57 | + } else { 58 | + fs.appendFileSync( 59 | + path.resolve(__dirname, './import-order-plugin.log'), 60 | + _message, 61 | + 'utf8' 62 | + ); 63 | + } 64 | + } 65 | + 66 | + function compare(a, b) { 67 | + if(pluginConfig) { 68 | + 69 | + // always tries to match with regexes that have higher prio first (moveUpRegexes > moveDownRegexes; moveXRegexes[n+1] > moveXRegexes[n]) 70 | + // moveDownRegexes.length is the default value for when no regex matches the moduleSpecifier 71 | + // no match has higher prio over moveDownRegexes match 72 | + 73 | + const def = moveDownRegexes.length 74 | + let aSort = moveUpRegexes.findLastIndex((re) => re.test(a.moduleSpecifier)) + def + 1 75 | + let bSort = moveUpRegexes.findLastIndex((re) => re.test(b.moduleSpecifier)) + def + 1 76 | + 77 | + aSort = aSort === def ? moveDownRegexes.findLastIndex((re) => re.test(a.moduleSpecifier)) : aSort 78 | + bSort = bSort === def ? moveDownRegexes.findLastIndex((re) => re.test(b.moduleSpecifier)) : bSort 79 | + 80 | + aSort = ~aSort ? aSort : def 81 | + bSort = ~bSort ? bSort : def 82 | + 83 | + return bSort - aSort; 84 | + } else { 85 | + return 0 86 | + } 87 | + } 88 | + 89 | + function getOverridenModule(symbol) { 90 | + return overrides.get(symbol) 91 | + } 92 | + 93 | + const pr = getProjectRoot(); 94 | + 95 | + if(!pr) { 96 | + log("Cannot read import-order-plugin root"); 97 | + } else { 98 | + log("starting import plugin with root " + getProjectRoot(), true) 99 | + 100 | + try { 101 | + const filePath = path.resolve(pr, 'tsconfig.plugins.json'); 102 | + const content = fs.readFileSync(filePath, 'utf8'); 103 | + pluginConfig = JSON.parse(content).compilerOptions?.plugins?.find(p => p.name === "ts-plugin-sort-import-suggestions"); 104 | + 105 | + if(pluginConfig) { 106 | + // reverse arrays so that the position is the right prio (higher index means higher prio) 107 | + moveUpRegexes = pluginConfig.moveUpPatterns.map((_) => new RegExp(_)).toReversed(); 108 | + moveDownRegexes = pluginConfig.moveDownPatterns.map((_) => new RegExp(_)).toReversed(); 109 | + 110 | + if(pluginConfig.overrides) { 111 | + Object.entries(pluginConfig.overrides).forEach(([module, symbols]) => { 112 | + for (const symbol of symbols) { 113 | + overrides.set(symbol, module) 114 | + } 115 | + }) 116 | + } 117 | + } 118 | + 119 | + log("Successfully read import-order-plugin configuration:"); 120 | + log(pluginConfig) 121 | + 122 | + } catch(e) { 123 | + log("Cannot read import-order-plugin configuration: " + e.message); 124 | + } 125 | + } 126 | + 127 | + return { 128 | + log, 129 | + compare, 130 | + inverseCompare: (a, b) => compare(b, a), 131 | + getOverridenModule, 132 | + } 133 | +})(); 134 | function sortFixInfo(fixes, sourceFile, program, packageJsonImportFilter, host, preferences) { 135 | const _toPath = (fileName) => toPath(fileName, host.getCurrentDirectory(), hostGetCanonicalFileName(host)); 136 | - return toSorted(fixes, (a, b) => compareBooleans(!!a.isJsxNamespaceFix, !!b.isJsxNamespaceFix) || compareValues(a.fix.kind, b.fix.kind) || compareModuleSpecifiers(a.fix, b.fix, sourceFile, program, preferences, packageJsonImportFilter.allowsImportingSpecifier, _toPath)); 137 | + const sortedFixes = toSorted(fixes, (a, b) => compareBooleans(!!a.isJsxNamespaceFix, !!b.isJsxNamespaceFix) || compareValues(a.fix.kind, b.fix.kind) || compareModuleSpecifiers(a.fix, b.fix, sourceFile, program, preferences, packageJsonImportFilter.allowsImportingSpecifier, _toPath)); 138 | + 139 | + if(sortedFixes.length !== 0) { 140 | + const symbolName = sortedFixes[0].symbolName; 141 | + const override = autoImportOrderModule.getOverridenModule(symbolName); 142 | + 143 | + if(override) { 144 | + const idx = sortedFixes.findIndex(f => f.fix.moduleSpecifier === override) 145 | + if(idx !== -1 && idx !== 0) { 146 | + const temp = sortedFixes[0] 147 | + sortedFixes[0] = sortedFixes[idx] 148 | + sortedFixes[idx] = temp 149 | + } 150 | + } 151 | + } 152 | + 153 | + return sortedFixes; 154 | } 155 | function getFixInfosWithoutDiagnostic(context, symbolToken, useAutoImportProvider) { 156 | const info = getFixesInfoForNonUMDImport(context, symbolToken, useAutoImportProvider); 157 | @@ -158243,9 +158362,11 @@ function getFixInfosWithoutDiagnostic(context, symbolToken, useAutoImportProvide 158 | } 159 | function getBestFix(fixes, sourceFile, program, packageJsonImportFilter, host, preferences) { 160 | if (!some(fixes)) return; 161 | + // These will always be placed first if available, and are better than other kinds 162 | if (fixes[0].kind === 0 /* UseNamespace */ || fixes[0].kind === 2 /* AddToExisting */) { 163 | return fixes[0]; 164 | } 165 | + 166 | return fixes.reduce( 167 | (best, fix) => ( 168 | // Takes true branch of conditional if `fix` is better than `best` 169 | @@ -158263,13 +158384,19 @@ function getBestFix(fixes, sourceFile, program, packageJsonImportFilter, host, p 170 | } 171 | function compareModuleSpecifiers(a, b, importingFile, program, preferences, allowsImportingSpecifier, toPath3) { 172 | if (a.kind !== 0 /* UseNamespace */ && b.kind !== 0 /* UseNamespace */) { 173 | - return compareBooleans( 174 | + return false 175 | + || compareBooleans( 176 | b.moduleSpecifierKind !== "node_modules" || allowsImportingSpecifier(b.moduleSpecifier), 177 | a.moduleSpecifierKind !== "node_modules" || allowsImportingSpecifier(a.moduleSpecifier) 178 | - ) || compareModuleSpecifierRelativity(a, b, preferences) || compareNodeCoreModuleSpecifiers(a.moduleSpecifier, b.moduleSpecifier, importingFile, program) || compareBooleans( 179 | + ) 180 | + || compareModuleSpecifierRelativity(a, b, preferences) 181 | + || compareNodeCoreModuleSpecifiers(a.moduleSpecifier, b.moduleSpecifier, importingFile, program) 182 | + || compareBooleans( 183 | isFixPossiblyReExportingImportingFile(a, importingFile.path, toPath3), 184 | isFixPossiblyReExportingImportingFile(b, importingFile.path, toPath3) 185 | - ) || compareNumberOfDirectorySeparators(a.moduleSpecifier, b.moduleSpecifier); 186 | + ) 187 | + || autoImportOrderModule.compare(a, b) 188 | + || compareNumberOfDirectorySeparators(a.moduleSpecifier, b.moduleSpecifier); 189 | } 190 | return 0 /* EqualTo */; 191 | } 192 | --------------------------------------------------------------------------------