Skip to content

Commit

Permalink
refactor: rewrite server js with typescript
Browse files Browse the repository at this point in the history
  • Loading branch information
riipandi committed Sep 16, 2024
1 parent ea5e362 commit 7b798c1
Show file tree
Hide file tree
Showing 26 changed files with 1,864 additions and 2,635 deletions.
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Dockerfile
node_modules
npm-debug.log
slim.report.json
playwright-report/
tests-results/
.playwright/
.git
.gitignore
Expand Down
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
APP_DOMAIN=localhost:3000
APP_BASE_URL=http://${APP_DOMAIN}
APP_BASE_URL=http://localhost:3000
APP_SECRET_KEY=replace_this_with_random_string
APP_LOG_LEVEL=debug
DATABASE_URL=postgresql://postgres:securedb@localhost:5432/remixdb
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
*tsbuildinfo
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
Expand Down Expand Up @@ -56,6 +57,7 @@ fly*.toml*
!fly*.example.toml

# Miscellaneous
vite.config.ts.*
RELEASE_NOTE.md
.unikraft/
slim.report.json
Expand Down
22 changes: 22 additions & 0 deletions .swcrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"minify": false,
"jsc": {
"minify": {
"compress": {
"unused": true
},
"mangle": true
},
"parser": {
"syntax": "typescript",
"tsx": false,
"decorators": true,
"dynamicImport": true
},
"target": "es2022",
"baseUrl": "."
},
"module": {
"type": "es6"
}
}
9 changes: 4 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,11 @@ Optionally, you can use [Docker Slim][docker-slim] to reduce the container image
### Up and Running

1. Install the required toolchain & SDK: [Node.js][nodejs], [pnpm][pnpm], and [Docker][docker].
2. Install required project dependencies: `pnpm install`
3. Create `.env` file or copy from `.env.example`, then configure required variables.
4. Generate application secret key: `pnpm generate:key`
2. Create `.env` file or copy from `.env.example`, then configure required variables.
3. Generate application secret key: `pnpm generate:key`
4. Install required project dependencies: `pnpm install`
5. Start the database server and local SMTP server: `pnpm pre-dev`
6. Prepare database migrations: `pnpm db:generate`
7. Run project in development mode: `pnpm dev`
6. Run project in development mode: `pnpm dev`

If you don't have OpenSSL installed, an alternative option for generating a secret key
is to use [1password][1password] to create a random secret.
Expand Down
18 changes: 8 additions & 10 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
# Arguments with default value (for build).
ARG RUN_IMAGE=gcr.io/distroless/nodejs20-debian12
ARG PLATFORM=linux/amd64
ARG NODE_ENV=production
ARG NODE_VERSION=20

# -----------------------------------------------------------------------------
Expand Down Expand Up @@ -31,24 +30,22 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install \
# Compile the application and install production only dependencies.
# -----------------------------------------------------------------------------
FROM base AS pruner
ENV LEFTHOOK=0 CI=true PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=true
ENV NODE_ENV $NODE_ENV
ENV LEFTHOOK=0 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=true NODE_ENV=production

# Required source files
COPY --from=builder /srv/.npmrc /srv/.npmrc
COPY --from=builder /srv/package.json /srv/package.json
COPY --from=builder /srv/server /srv/server
COPY --from=builder /srv/.npmrc /srv/.npmrc

# Generated files
COPY --from=builder /srv/pnpm-lock.yaml /srv/pnpm-lock.yaml
COPY --from=builder /srv/build/client /srv/build/client
COPY --from=builder /srv/build/server /srv/build/server
COPY --from=builder /srv/dist/runner /srv/dist/runner
COPY --from=builder /srv/dist/remix /srv/dist/remix

# Install production dependencies and cleanup node_modules.
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod \
--frozen-lockfile --ignore-scripts && pnpm prune --prod \
--ignore-scripts && pnpm dlx clean-modules clean --yes \
"!**/@libsql/**" && chmod +x /srv/server/server.js
"!**/@libsql/**" && chmod +x /srv/dist/runner/server.js

# -----------------------------------------------------------------------------
# Production image, copy build output files and run the application.
Expand Down Expand Up @@ -82,13 +79,14 @@ COPY --chown=nonroot:nonroot --from=pruner /srv /srv
COPY --from=builder /usr/bin/tini /usr/bin/tini

# Define the host and port to listen on.
ARG NODE_ENV=production PORT=3000
ENV PNPM_HOME="/pnpm" PATH="$PNPM_HOME:$PATH"
ENV NODE_ENV=$NODE_ENV HOST=0.0.0.0 PORT=3000
ENV NODE_ENV=$NODE_ENV HOST=0.0.0.0 PORT=$PORT
ENV TINI_SUBREAPER=true

WORKDIR /srv
USER nonroot:nonroot
EXPOSE $PORT

ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/nodejs/bin/node", "server/server.js"]
CMD ["/nodejs/bin/node", "/srv/dist/runner/server.js"]
18 changes: 8 additions & 10 deletions Dockerfile.alpine
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

# Arguments with default value (for build).
ARG PLATFORM=linux/amd64
ARG NODE_ENV=production
ARG NODE_VERSION=20

# -----------------------------------------------------------------------------
Expand Down Expand Up @@ -30,24 +29,22 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install \
# Compile the application and install production only dependencies.
# -----------------------------------------------------------------------------
FROM base AS pruner
ENV LEFTHOOK=0 CI=true PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=true
ENV NODE_ENV $NODE_ENV
ENV LEFTHOOK=0 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=true NODE_ENV=production

# Required source files
COPY --from=builder /srv/.npmrc /srv/.npmrc
COPY --from=builder /srv/package.json /srv/package.json
COPY --from=builder /srv/server /srv/server
COPY --from=builder /srv/.npmrc /srv/.npmrc

# Generated files
COPY --from=builder /srv/pnpm-lock.yaml /srv/pnpm-lock.yaml
COPY --from=builder /srv/build/client /srv/build/client
COPY --from=builder /srv/build/server /srv/build/server
COPY --from=builder /srv/dist/runner /srv/dist/runner
COPY --from=builder /srv/dist/remix /srv/dist/remix

# Install production dependencies and cleanup node_modules.
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod \
--frozen-lockfile --ignore-scripts && pnpm prune --prod \
--ignore-scripts && pnpm dlx clean-modules clean --yes \
"!**/@libsql/**" && chmod +x /srv/server/server.js
"!**/@libsql/**" && chmod +x /srv/dist/runner/server.js

# -----------------------------------------------------------------------------
# Production image, copy build output files and run the application.
Expand Down Expand Up @@ -83,11 +80,12 @@ COPY --chown=nonroot:nonroot --from=pruner /srv /srv
COPY --from=builder /sbin/tini /sbin/tini

# Define the host and port to listen on.
ENV NODE_ENV=$NODE_ENV HOST=0.0.0.0 PORT=3000
ARG NODE_ENV=production PORT=3000
ENV NODE_ENV=$NODE_ENV HOST=0.0.0.0 PORT=$PORT
ENV TINI_SUBREAPER=true

USER nonroot:nonroot
EXPOSE $PORT

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/usr/local/bin/node", "server/server.js"]
CMD ["/usr/local/bin/node", "/srv/dist/runner/server.js"]
6 changes: 4 additions & 2 deletions app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import { RemixServer } from '@remix-run/react'
import { isbot } from 'isbot'
import { renderToPipeableStream } from 'react-dom/server'
import { NonceProvider } from '#/context/providers/nonce-provider'
import { getEnv } from '#/utils/env.server'
import { getClientEnv, initEnv } from '#/utils/env.server'
import { logger } from './utils/common'

global.ENV = getEnv()
initEnv()

global.ENV = getClientEnv()

const ABORT_DELAY = 5000

Expand Down
1 change: 1 addition & 0 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function Layout({ children }: PropsWithChildren) {
const nonce = useNonce()

useEffect(() => {
// Dynamic favicon, changed when dark mode changes or switches to other tab.
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const favicon = document.querySelector('link[rel="icon"][id="favicon-svg"]') as HTMLLinkElement
const FAVICON_URLS = { LIGHT: '/favicon.ico', DARK: '/favicon.svg', INACTIVE: '/favicon.png' }
Expand Down
3 changes: 2 additions & 1 deletion app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
font-weight: 200 1000;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/mulish:vf@latest/latin-wght-normal.woff2)
format("woff2-variations");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF,
U+FFFD;
}
Expand Down
13 changes: 11 additions & 2 deletions app/utils/common.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import pico from 'picocolors'
import type { LogLevel } from './env.server'

export type EnumValues<Type> = Type[keyof Type]
/**
* Determines if the current environment is a browser.
* @returns `true` if the current environment is a browser, `false` otherwise.
*/
export function isBrowser() {
return typeof window !== 'undefined'
}

/**
* Generates a formatted timestamp string.
Expand Down Expand Up @@ -75,7 +81,10 @@ function log(level: LogLevel, message: string | unknown, ...args: unknown[]): vo
const logMessage = ` ${cleanedMessage}` // Space after prefix for consistent alignment

// Handle silent mode for debug logs
if (level === 'debug' && process.env.APP_LOG_LEVEL?.toLowerCase() === 'silent') {
const env = isBrowser() ? window.ENV : process.env
const isDebugSilent = level === 'debug' && env.APP_LOG_LEVEL?.toLowerCase() === 'silent'

if (isDebugSilent) {
return
}

Expand Down
19 changes: 14 additions & 5 deletions app/utils/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { logger } from './common'
const LogLevelSchema = v.picklist(['info', 'warn', 'error', 'debug', 'query'] as const)

const EnvSchema = v.object({
NODE_ENV: v.picklist(['production', 'development', 'test'] as const),
NODE_ENV: v.optional(v.picklist(['production', 'development', 'test'] as const), 'development'),
APP_DOMAIN: v.string(),
APP_BASE_URL: v.string(),
APP_SECRET_KEY: v.string(),
Expand All @@ -26,7 +26,16 @@ const EnvSchema = v.object({
v.url('The url is badly formatted.')
),
SMTP_HOST: v.optional(v.string(), 'localhost'),
SMTP_PORT: v.optional(v.number(), 1025),
SMTP_PORT: v.nullable(
v.pipe(
v.string(),
v.transform((input) => {
const parsed = Number.parseInt(input.toString())
return Number.isNaN(parsed) ? 1025 : parsed
})
),
'1025'
),
SMTP_USERNAME: v.optional(v.string()),
SMTP_PASSWORD: v.optional(v.string()),
SMTP_EMAIL_FROM: v.optional(v.string(), 'Remix Mailer <[email protected]>'),
Expand All @@ -40,7 +49,7 @@ declare global {
}
}

export function init() {
export function initEnv() {
try {
const parsed = v.parse(EnvSchema, process.env)
logger.debug('EnvSchema', parsed)
Expand All @@ -59,7 +68,7 @@ export function init() {
* wish to be included in the client.
* @returns all public ENV variables
*/
export function getEnv() {
export function getClientEnv() {
return {
NODE_ENV: process.env.NODE_ENV,
APP_DOMAIN: process.env.APP_DOMAIN,
Expand All @@ -68,7 +77,7 @@ export function getEnv() {
}
}

type ENV = ReturnType<typeof getEnv>
type ENV = ReturnType<typeof getClientEnv>

declare global {
var ENV: ENV
Expand Down
64 changes: 52 additions & 12 deletions app/utils/request.server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
const IP_HEADERS = [
import { isIP } from 'is-ip'

/**
* This is the list of headers, in order of preference, that will be used to
* determine the client's IP address.
*/
const IP_HEADERS = Object.freeze([
'CF-Connecting-IP', // Cloudflare
'DO-Connecting-IP' /** Digital ocean app platform */,
'Fastly-Client-Ip', // Fastly CDN and Firebase hosting header when forwared to a cloud function
'Fly-Client-IP', // Fly.io
'Forwarded-For',
Expand All @@ -12,6 +19,8 @@ const IP_HEADERS = [
'HTTP_X_CLUSTER_CLIENT_IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_FORWARDED',
'HTTP-X-Forwarded-For',
'oxygen-buyer-ip' /** Shopify oxygen platform */,
'Proxy-Client-IP',
'REMOTE_ADDR',
'True-Client-Ip', // Akamai and Cloudflare
Expand All @@ -21,19 +30,50 @@ const IP_HEADERS = [
'X-Forwarded-For', // may contain multiple IP addresses in the format: 'client IP, proxy 1 IP, proxy 2 IP' - we use first one
'X-Forwarded',
'X-Real-IP', // Nginx proxy, FastCGI
// you can add more matching headers here ...
]

export function getRequestIpAddress(request: Request): string | null {
const headers = request.headers
] as const)

for (const header of IP_HEADERS) {
const value = headers.get(header)
if (value) {
const parts = value.split(/\s*,\s*/g)
return parts[0] ?? null
}
/**
* Receives a Request or Headers objects.
* If it's a Request returns the request.headers
* If it's a Headers returns the object directly.
*/
export function getHeaders(requestOrHeaders: Request | Headers): Headers {
if (requestOrHeaders instanceof Request) {
return requestOrHeaders.headers
}

return requestOrHeaders
}

function parseForwardedHeader(value: string | null): string | null {
if (!value) return null
for (const part of value.split(';')) {
if (part.startsWith('for=')) return part.slice(4)
}
return null
}

/*!
* Portions of this function are based on code from `sergiodxa/remix-utils`.
* MIT Licensed, Copyright (c) 2021 Sergio Xalambrí.
*
* Credits to Alexandru Bereghici:
* Source: https://github.com/sergiodxa/remix-utils/blob/main/src/server/get-client-ip-address.ts
*/
export function getRequestIpAddress(requestOrHeaders: Request | Headers): string | null {
const headers = getHeaders(requestOrHeaders)

const ipAddress = IP_HEADERS.flatMap((headerName) => {
const value = headers.get(headerName)
if (headerName === 'Forwarded') {
return parseForwardedHeader(value)
}
if (!value?.includes(',')) return value
return value.split(',').map((ip) => ip.trim())
}).find((ip) => {
if (ip === null) return false
return isIP(ip)
})

return ipAddress ?? null
}
Loading

0 comments on commit 7b798c1

Please sign in to comment.