Merged in feat/sw-2859-set-up-shared-trpc-package (pull request #2319)

feat(SW-2859): Create trpc package

* Add isEdge, safeTry and dataCache to new common package

* Add eslint and move prettier config

* Clean up tests

* Create trpc package and move initialization

* Move errors and a few procedures

* Move telemetry to common package

* Move tokenManager to common package

* Add Sentry to procedures

* Clean up procedures

* Fix self-referencing imports

* Add exports to packages and lint rule to prevent relative imports

* Add env to trpc package

* Add eslint to trpc package

* Apply lint rules

* Use direct imports from trpc package

* Add lint-staged config to trpc

* Move lang enum to common

* Restructure trpc package folder structure

* Fix lang imports


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-06-18 12:14:20 +00:00
parent 2f38bdf0b1
commit 846fd904a6
211 changed files with 989 additions and 627 deletions

18
packages/trpc/env/server.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"
export const env = createEnv({
/**
* Due to t3-env only checking typeof window === "undefined"
* and Netlify running Deno, window is never "undefined"
* https://github.com/t3-oss/t3-env/issues/154
*/
isServer: typeof window === "undefined" || "Deno" in window,
server: {
NODE_ENV: z.enum(["development", "test", "production"]),
},
emptyStringAsUndefined: true,
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
},
})

View File

@@ -0,0 +1,89 @@
import { FlatCompat } from "@eslint/eslintrc"
import js from "@eslint/js"
import typescriptEslint from "@typescript-eslint/eslint-plugin"
import tsParser from "@typescript-eslint/parser"
import { defineConfig } from "eslint/config"
import simpleImportSort from "eslint-plugin-simple-import-sort"
import importPlugin from "eslint-plugin-import"
const compat = new FlatCompat({
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
})
export default defineConfig([
{
files: ["**/*.ts", "**/*.tsx"],
extends: compat.extends("plugin:import/typescript"),
plugins: {
"simple-import-sort": simpleImportSort,
"@typescript-eslint": typescriptEslint,
import: importPlugin,
},
linterOptions: {
reportUnusedDisableDirectives: true,
},
languageOptions: {
parser: tsParser,
},
rules: {
"no-unused-vars": "off",
"import/no-relative-packages": "error",
"simple-import-sort/imports": [
"error",
{
groups: [
["^\\u0000"],
["^node:"],
["^@?\\w"],
["^@scandic-hotels/(?!.*\u0000$).*$"],
[
"^@/constants/?(?!.*\u0000$).*$",
"^@/env/?(?!.*\u0000$).*$",
"^@/lib/?(?!.*\u0000$).*$",
"^@/server/?(?!.*\u0000$).*$",
"^@/stores/?(?!.*\u0000$).*$",
],
["^@/(?!(types|.*\u0000$)).*$"],
[
"^\\.\\.(?!/?$)",
"^\\.\\./?$",
"^\\./(?=.*/)(?!/?$)",
"^\\.(?!/?$)",
"^\\./?$",
],
["^(?!\\u0000).+\\.s?css$"],
["^node:.*\\u0000$", "^@?\\w.*\\u0000$"],
[
"^@scandichotels/.*\\u0000$",
"^@/types/.*",
"^@/.*\\u0000$",
"^[^.].*\\u0000$",
"^\\..*\\u0000$",
],
],
},
],
"simple-import-sort/exports": "error",
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
args: "all",
argsIgnorePattern: "^_",
caughtErrors: "all",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
varsIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
},
},
])

5
packages/trpc/global.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
import type { DataCache } from "./dataCache/Cache"
declare global {
var cacheClient: Promise<DataCache> | undefined
}

View File

@@ -0,0 +1,34 @@
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { User } from "next-auth"
import type { JWT } from "next-auth/jwt"
type Session = {
token: JWT
expires: string
user?: User
error?: "RefreshAccessTokenError"
}
type CreateContextOptions = {
auth: () => Promise<Session | null>
lang: Lang
pathname: string
uid?: string | null
url: string
webToken?: string
contentType?: string
}
export function createContext(opts: CreateContextOptions) {
return {
auth: opts.auth,
lang: opts.lang,
pathname: opts.pathname,
uid: opts.uid,
url: opts.url,
webToken: opts.webToken,
contentType: opts.contentType,
}
}
export type Context = Awaited<ReturnType<typeof createContext>>

View File

@@ -0,0 +1,95 @@
import { TRPCError } from "@trpc/server"
export function unauthorizedError(cause?: unknown) {
return new TRPCError({
code: "UNAUTHORIZED",
message: `Unauthorized`,
cause,
})
}
export function forbiddenError(cause?: unknown) {
return new TRPCError({
code: "FORBIDDEN",
message: `Forbidden`,
cause,
})
}
export function conflictError(cause?: unknown) {
return new TRPCError({
code: "CONFLICT",
message: `Conflict`,
cause,
})
}
export function badRequestError(cause?: unknown) {
return new TRPCError({
code: "BAD_REQUEST",
message: `Bad request`,
cause,
})
}
export function notFound(cause?: unknown) {
return new TRPCError({
code: "NOT_FOUND",
message: `Not found`,
cause,
})
}
export function unprocessableContent(cause?: unknown) {
return new TRPCError({
code: "UNPROCESSABLE_CONTENT",
message: "Unprocessable content",
cause,
})
}
export function internalServerError(cause?: unknown) {
return new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Internal Server Error`,
cause,
})
}
export const SESSION_EXPIRED = "SESSION_EXPIRED"
export class SessionExpiredError extends Error {}
export function sessionExpiredError() {
return new TRPCError({
code: "UNAUTHORIZED",
message: SESSION_EXPIRED,
cause: new SessionExpiredError(SESSION_EXPIRED),
})
}
export const PUBLIC_UNAUTHORIZED = "PUBLIC_UNAUTHORIZED"
export class PublicUnauthorizedError extends Error {}
export function publicUnauthorizedError() {
return new TRPCError({
code: "UNAUTHORIZED",
message: PUBLIC_UNAUTHORIZED,
cause: new PublicUnauthorizedError(PUBLIC_UNAUTHORIZED),
})
}
export function serverErrorByStatus(status: number, cause?: unknown) {
switch (status) {
case 401:
return unauthorizedError(cause)
case 403:
return forbiddenError(cause)
case 404:
return notFound(cause)
case 409:
return conflictError(cause)
case 422:
return unprocessableContent(cause)
case 500:
default:
return internalServerError(cause)
}
}

View File

@@ -0,0 +1,39 @@
import { initTRPC } from "@trpc/server"
import { ZodError } from "zod"
import { transformer } from "./transformer"
import type { Context } from "./context"
export type Meta = {
authRequired?: boolean
}
const t = initTRPC
.context<Context>()
.meta<Meta>()
.create({
transformer,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
cause:
error.cause instanceof ZodError
? undefined
: JSON.parse(JSON.stringify(error.cause)),
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
}
},
})
export const {
createCallerFactory,
mergeRouters,
router,
procedure,
middleware,
} = t

View File

@@ -0,0 +1,164 @@
import * as Sentry from "@sentry/nextjs"
import { getServiceToken } from "@scandic-hotels/common/tokenManager"
import { env } from "../env/server"
import {
badRequestError,
internalServerError,
sessionExpiredError,
unauthorizedError,
} from "./errors"
import { langInput } from "./utils"
import { middleware, procedure } from "."
const sentryMiddleware = middleware(
Sentry.trpcMiddleware({
attachRpcInput: true,
})
)
export const baseProcedure = procedure.use(sentryMiddleware)
export const publicProcedure = baseProcedure
export const languageProcedure = baseProcedure.use(async function (opts) {
if (!opts.ctx.lang) {
// When fetching data client side with TRPC we don't pass through middlewares and therefore do not get the lang through headers
// We can then pass lang as an input in the request and set it to the context in the procedure
const input = await opts.getRawInput()
const parsedInput = langInput.safeParse(input)
if (!parsedInput.success) {
throw badRequestError("Missing Lang in tRPC context")
}
return opts.next({
ctx: {
lang: parsedInput.data.lang,
},
})
}
return opts.next({
ctx: {
lang: opts.ctx.lang,
},
})
})
export const contentstackBaseProcedure = languageProcedure
export const contentstackExtendedProcedureUID = contentstackBaseProcedure.use(
async function (opts) {
if (!opts.ctx.uid) {
throw badRequestError("Missing UID in tRPC context")
}
return opts.next({
ctx: {
uid: opts.ctx.uid,
},
})
}
)
export const protectedProcedure = baseProcedure.use(async function (opts) {
const authRequired = opts.meta?.authRequired ?? true
const session = await opts.ctx.auth()
if (!authRequired && env.NODE_ENV === "development") {
console.info(
`❌❌❌❌ You are opting out of authorization, if its done on purpose maybe you should use the publicProcedure instead. ❌❌❌❌`
)
console.info(`path: ${opts.path} | type: ${opts.type}`)
}
if (!session) {
throw unauthorizedError()
}
if (session?.error === "RefreshAccessTokenError") {
throw sessionExpiredError()
}
return opts.next({
ctx: {
session,
},
})
})
export const safeProtectedProcedure = baseProcedure.use(async function (opts) {
const authRequired = opts.meta?.authRequired ?? true
let session = await opts.ctx.auth()
if (!authRequired && env.NODE_ENV === "development") {
console.info(
`❌❌❌❌ You are opting out of authorization, if its done on purpose maybe you should use the publicProcedure instead. ❌❌❌❌`
)
console.info(`path: ${opts.path} | type: ${opts.type}`)
}
if (!session || session.error === "RefreshAccessTokenError") {
session = null
}
return opts.next({
ctx: {
session,
},
})
})
export const serviceProcedure = baseProcedure.use(async (opts) => {
const token = await getServiceToken()
const { access_token } = token
if (!access_token) {
throw internalServerError(`[serviceProcedure] No service token`)
}
return opts.next({
ctx: {
serviceToken: access_token,
},
})
})
export const contentStackUidWithServiceProcedure =
contentstackExtendedProcedureUID.concat(serviceProcedure)
export const contentStackBaseWithServiceProcedure =
contentstackBaseProcedure.concat(serviceProcedure)
export const contentStackBaseWithProtectedProcedure =
contentstackBaseProcedure.concat(protectedProcedure)
export const safeProtectedServiceProcedure =
safeProtectedProcedure.concat(serviceProcedure)
export const languageProtectedProcedure =
protectedProcedure.concat(languageProcedure)
type ExperimentalProcedureCaller = ReturnType<
typeof baseProcedure.experimental_caller
>
export function getProtectedServerActionProcedure(
serverActionProcedure: ExperimentalProcedureCaller
) {
return serverActionProcedure.use(async (opts) => {
const session = await opts.ctx.auth()
if (!session) {
throw unauthorizedError()
}
if (session && session.error === "RefreshAccessTokenError") {
throw sessionExpiredError()
}
return opts.next({
ctx: {
...opts.ctx,
session,
},
})
})
}

View File

@@ -0,0 +1,3 @@
import superjson from "superjson"
export const transformer = superjson

View File

@@ -0,0 +1,7 @@
import { z } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
export const langInput = z.object({
lang: z.nativeEnum(Lang),
})

View File

@@ -0,0 +1,6 @@
const config = {
"*.{ts,tsx}": [() => "tsc -p tsconfig.json --noEmit", "prettier --write"],
"*.{js,cjs,mjs}": "prettier --write",
}
export default config

View File

@@ -0,0 +1,38 @@
{
"name": "@scandic-hotels/trpc",
"version": "1.0.0",
"main": "index.ts",
"type": "module",
"scripts": {
"check-types": "tsc --noEmit",
"lint": "eslint . --max-warnings 0 && tsc --noEmit"
},
"exports": {
".": "./lib/index.ts",
"./context": "./lib/context.ts",
"./errors": "./lib/errors.ts",
"./procedures": "./lib/procedures.ts",
"./transformer": "./lib/transformer.ts"
},
"dependencies": {
"@scandic-hotels/common": "workspace:*",
"@sentry/nextjs": "^8.41.0",
"@t3-oss/env-nextjs": "^0.13.4",
"@trpc/server": "^11.1.2",
"next-auth": "5.0.0-beta.27",
"superjson": "^2.2.2",
"zod": "^3.24.4"
},
"devDependencies": {
"@eslint/compat": "^1.2.9",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.26.0",
"@types/lodash-es": "^4",
"@typescript-eslint/eslint-plugin": "^8.32.0",
"@typescript-eslint/parser": "^8.32.0",
"eslint": "^9",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"typescript": "5.8.3"
}
}

View File

@@ -0,0 +1,8 @@
module.exports = {
semi: false,
trailingComma: "es5",
singleQuote: false,
printWidth: 80,
tabWidth: 2,
endOfLine: "lf",
};

View File

@@ -0,0 +1,10 @@
{
"extends": "@scandic-hotels/typescript-config/base.json",
"compilerOptions": {
"paths": {
"@/*": ["./*"]
}
},
"include": ["**/*.ts"],
"exclude": ["**/node_modules/**"]
}