@podosoft/podokit 0.16.3 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/deploy-compose-exec.d.ts +93 -0
- package/dist/deploy-compose-exec.js +198 -0
- package/dist/deploy-compose-profile.d.ts +107 -0
- package/dist/deploy-compose-profile.js +361 -0
- package/dist/deploy-compose-render.d.ts +48 -0
- package/dist/deploy-compose-render.js +333 -0
- package/dist/deploy-compose-sync.d.ts +144 -0
- package/dist/deploy-compose-sync.js +412 -0
- package/dist/deploy-compose.d.ts +98 -0
- package/dist/deploy-compose.js +667 -0
- package/dist/deploy-dispatch.d.ts +33 -0
- package/dist/deploy-dispatch.js +58 -0
- package/dist/deploy-driver.d.ts +25 -0
- package/dist/deploy-driver.js +78 -0
- package/dist/deploy-profile.d.ts +4 -7
- package/dist/deploy-profile.js +86 -274
- package/dist/deploy-render.js +10 -1
- package/dist/deploy-schema.d.ts +56 -0
- package/dist/deploy-schema.js +261 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +178 -6
- package/dist/lib.d.ts +9 -0
- package/dist/lib.js +30 -1
- package/dist/templates/fullstack-nest-svelte/AGENTS.md +2 -1
- package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +34 -3
- package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +68 -7
- package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +60 -8
- package/dist/templates/fullstack-nest-svelte/apps/web/package.json +2 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/server.js +31 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/api.ts +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +24 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.js +267 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/api/[...path]/+server.ts +6 -2
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/api/auth/[...all]/+server.ts +6 -2
- package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +17 -7
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +50 -1
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/release.md +64 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
- package/dist/templates/fullstack-nest-svelte/dot-dockerignore +7 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.example +7 -0
- package/dist/templates/fullstack-nest-svelte/dot-github/workflows/release.yml +142 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/README.md +18 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +6 -1
- package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +8 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/web-deployment.yaml +11 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.test.ts +62 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.ts +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +3 -2
- package/dist/templates/modules/bullmq/files/infra/k3s/worker-deployment.yaml +2 -1
- package/dist/templates/todo/AGENTS.md +2 -1
- package/dist/templates/todo/Dockerfile.dev +34 -3
- package/dist/templates/todo/apps/api/Dockerfile +68 -7
- package/dist/templates/todo/apps/web/Dockerfile +60 -8
- package/dist/templates/todo/apps/web/package.json +2 -1
- package/dist/templates/todo/apps/web/server.js +31 -0
- package/dist/templates/todo/apps/web/src/lib/server/api.ts +2 -2
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +24 -1
- package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.js +267 -0
- package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
- package/dist/templates/todo/apps/web/src/routes/api/[...path]/+server.ts +6 -2
- package/dist/templates/todo/apps/web/src/routes/api/auth/[...all]/+server.ts +6 -2
- package/dist/templates/todo/apps/web/vite.config.ts +17 -7
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +47 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/release.md +64 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
- package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
- package/dist/templates/todo/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
- package/dist/templates/todo/dot-dockerignore +7 -0
- package/dist/templates/todo/dot-env.example +7 -0
- package/dist/templates/todo/dot-github/workflows/release.yml +142 -0
- package/dist/templates/todo/infra/k3s/README.md +18 -0
- package/dist/templates/todo/infra/k3s/api-deployment.yaml +6 -1
- package/dist/templates/todo/infra/k3s/ingress.yaml +8 -0
- package/dist/templates/todo/infra/k3s/web-deployment.yaml +11 -1
- package/package.json +1 -1
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
# Development image for the PodoKit monorepo (web + api).
|
|
2
2
|
# Dependencies and source are baked in; `docker compose watch` syncs your
|
|
3
3
|
# edits into the running container so the native file watchers fire.
|
|
4
|
+
|
|
5
|
+
# Every workspace manifest, collected rather than listed.
|
|
6
|
+
#
|
|
7
|
+
# Listing them by hand only works for the workspaces this template starts with. A
|
|
8
|
+
# project that adds `packages/*` then has workspace manifests that never reach the
|
|
9
|
+
# image, so npm does not know those names are local and goes looking for them on the
|
|
10
|
+
# public registry — `npm error 404 '@scope/thing' is not in this registry`, from an
|
|
11
|
+
# install that succeeds on the developer's own machine. The production images
|
|
12
|
+
# collect manifests for exactly this reason; this one was the exception until a
|
|
13
|
+
# generated project with shared packages could not start its dev stack at all.
|
|
14
|
+
FROM node:22-bookworm-slim AS manifests
|
|
15
|
+
WORKDIR /src
|
|
16
|
+
COPY . .
|
|
17
|
+
RUN mkdir -p /app \
|
|
18
|
+
&& cp package.json /app/ \
|
|
19
|
+
&& find . -name package.json -not -path "*/node_modules/*" \
|
|
20
|
+
-exec sh -c 'mkdir -p "/app/$(dirname "$1")" && cp "$1" "/app/$1"' _ {} \;
|
|
21
|
+
|
|
4
22
|
FROM node:22-bookworm-slim
|
|
5
23
|
|
|
6
24
|
# git for the toolchain (editors, AI agents); build tools for native modules.
|
|
@@ -14,9 +32,7 @@ WORKDIR /app
|
|
|
14
32
|
# NB: the host package-lock.json is intentionally NOT copied — a lockfile
|
|
15
33
|
# generated on macOS/Windows prunes platform-specific optional deps and can drop
|
|
16
34
|
# packages (e.g. @swc/cli) on linux. Resolving fresh in-container avoids that.
|
|
17
|
-
COPY
|
|
18
|
-
COPY apps/api/package.json ./apps/api/package.json
|
|
19
|
-
COPY apps/web/package.json ./apps/web/package.json
|
|
35
|
+
COPY --from=manifests /app ./
|
|
20
36
|
ARG PODOKIT_NPM_REGISTRY=""
|
|
21
37
|
RUN if [ -n "$PODOKIT_NPM_REGISTRY" ]; then npm config set registry "$PODOKIT_NPM_REGISTRY"; fi \
|
|
22
38
|
&& npm install
|
|
@@ -24,4 +40,19 @@ RUN if [ -n "$PODOKIT_NPM_REGISTRY" ]; then npm config set registry "$PODOKIT_NP
|
|
|
24
40
|
# Baked source (kept in sync during development by `docker compose watch`).
|
|
25
41
|
COPY . .
|
|
26
42
|
|
|
43
|
+
# Workspace packages compile before the apps that import them. Apps import them by
|
|
44
|
+
# their published entry point (`dist/index.js`), so without this the API starts and
|
|
45
|
+
# dies on MODULE_NOT_FOUND for a package whose symlink is right there — the link
|
|
46
|
+
# resolves, the built file behind it does not exist. `--if-present` keeps a project
|
|
47
|
+
# with no packages, or one whose packages need no build, working unchanged.
|
|
48
|
+
#
|
|
49
|
+
# `docker compose watch` syncs app source only, so a change under packages/* needs
|
|
50
|
+
# this image rebuilt rather than a container restart.
|
|
51
|
+
RUN if [ -d packages ]; then \
|
|
52
|
+
for manifest in packages/*/package.json; do \
|
|
53
|
+
[ -e "$manifest" ] || continue; \
|
|
54
|
+
npm run build --if-present --workspace "$(dirname "$manifest")"; \
|
|
55
|
+
done; \
|
|
56
|
+
fi
|
|
57
|
+
|
|
27
58
|
EXPOSE 5001 5002
|
|
@@ -1,27 +1,88 @@
|
|
|
1
1
|
# syntax=docker/dockerfile:1
|
|
2
|
+
|
|
3
|
+
# The npm that reads package-lock.json must be the major that wrote it. node:22
|
|
4
|
+
# bundles npm 10, and a lockfile written by npm 11 records platform-specific
|
|
5
|
+
# packages as extraneous with no `optional` flag; npm 10 rejects those with
|
|
6
|
+
# EBADPLATFORM instead of skipping them, so `npm ci` fails inside the image while
|
|
7
|
+
# `npm install` on the developer's machine is fine. Keep this in step with the npm
|
|
8
|
+
# the project is developed with.
|
|
9
|
+
ARG NPM_VERSION=11.17.0
|
|
10
|
+
|
|
11
|
+
# Every workspace manifest, and nothing else.
|
|
12
|
+
#
|
|
13
|
+
# Listing them by hand only works for the workspaces this template starts with: npm
|
|
14
|
+
# does NOT fail when a workspace's package.json is absent, it silently installs a
|
|
15
|
+
# tree without that package's dependencies. A project that adds packages/* then
|
|
16
|
+
# builds against different versions than it resolves locally, and only inside the
|
|
17
|
+
# image. Collecting them keeps `npm ci` correct whatever the workspace layout is,
|
|
18
|
+
# and the stages below still cache on the manifests' content.
|
|
19
|
+
FROM node:22-alpine AS manifests
|
|
20
|
+
WORKDIR /src
|
|
21
|
+
COPY . .
|
|
22
|
+
RUN mkdir -p /app \
|
|
23
|
+
&& cp package.json package-lock.json /app/ \
|
|
24
|
+
&& find . -name package.json -not -path "*/node_modules/*" \
|
|
25
|
+
-exec sh -c 'mkdir -p "/app/$(dirname "$1")" && cp "$1" "/app/$1"' _ {} \;
|
|
26
|
+
|
|
2
27
|
FROM node:22-alpine AS deps
|
|
3
28
|
WORKDIR /app
|
|
4
|
-
COPY
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
COPY tests/package.json ./tests/package.json
|
|
29
|
+
COPY --from=manifests /app ./
|
|
30
|
+
ARG NPM_VERSION
|
|
31
|
+
RUN npm i -g "npm@${NPM_VERSION}"
|
|
8
32
|
RUN npm ci --no-audit --no-fund
|
|
9
33
|
|
|
34
|
+
# Production dependencies only, resolved from the same lockfile. The runtime stage
|
|
35
|
+
# copies node_modules from here and never from deps or build, so the shipped image
|
|
36
|
+
# does not carry the TypeScript compiler, Vite, or the test tooling.
|
|
37
|
+
FROM node:22-alpine AS prod-deps
|
|
38
|
+
WORKDIR /app
|
|
39
|
+
COPY --from=manifests /app ./
|
|
40
|
+
ARG NPM_VERSION
|
|
41
|
+
RUN npm i -g "npm@${NPM_VERSION}"
|
|
42
|
+
# The runtime tree for this image only.
|
|
43
|
+
#
|
|
44
|
+
# Scoping to the one workspace matters: --omit=dev at the root still installs the
|
|
45
|
+
# union of every workspace's production dependencies, so the API image carried Vite
|
|
46
|
+
# and the web image carried Nest. Measured on a generated project, this is 198
|
|
47
|
+
# packages instead of 283. TypeScript stays either way -- typeorm depends on ts-node,
|
|
48
|
+
# which depends on it, so it is a production dependency and not bloat.
|
|
49
|
+
RUN npm ci --omit=dev --no-audit --no-fund --workspace=apps/api --include-workspace-root
|
|
50
|
+
|
|
10
51
|
FROM node:22-alpine AS build
|
|
11
52
|
WORKDIR /app
|
|
12
|
-
|
|
53
|
+
# Everything npm ci produced, not just the root node_modules: npm nests any
|
|
54
|
+
# dependency it cannot hoist, and copying only the root directory leaves a workspace
|
|
55
|
+
# package compiling against the wrong version of it.
|
|
56
|
+
COPY --from=deps /app ./
|
|
13
57
|
COPY . .
|
|
58
|
+
# Local workspace packages compile before the app that imports them. Apps import
|
|
59
|
+
# them by their published entry points and .dockerignore excludes dist, so nothing
|
|
60
|
+
# here has compiled output until it is compiled here.
|
|
61
|
+
RUN if [ -d packages ]; then \
|
|
62
|
+
for manifest in packages/*/package.json; do \
|
|
63
|
+
[ -e "$manifest" ] || continue; \
|
|
64
|
+
npm run build --if-present --workspace "$(dirname "$manifest")"; \
|
|
65
|
+
done; \
|
|
66
|
+
fi
|
|
14
67
|
RUN npm run build --workspace=apps/api
|
|
68
|
+
# The runtime needs the real workspace package directories, not just the symlinks
|
|
69
|
+
# npm leaves in node_modules pointing at them. Without this the image boots and
|
|
70
|
+
# dies on "Cannot find module <package>" -- a dangling link, at runtime only.
|
|
71
|
+
# mkdir keeps the COPY valid for a project that has no packages/ at all.
|
|
72
|
+
RUN mkdir -p /app/packages
|
|
15
73
|
|
|
16
74
|
FROM node:22-alpine AS runtime
|
|
17
75
|
WORKDIR /app
|
|
18
76
|
ENV NODE_ENV=production
|
|
19
77
|
RUN addgroup -S -g 101 app && adduser -S -D -H -u 100 -G app app
|
|
20
|
-
COPY --from=
|
|
21
|
-
COPY --from=
|
|
78
|
+
COPY --from=prod-deps /app/node_modules ./node_modules
|
|
79
|
+
COPY --from=manifests /app/apps/api ./apps/api
|
|
80
|
+
COPY --from=build /app/packages ./packages
|
|
22
81
|
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
|
23
82
|
COPY --from=build /app/apps/api/scripts ./apps/api/scripts
|
|
24
83
|
WORKDIR /app/apps/api
|
|
25
84
|
USER 100:101
|
|
26
85
|
EXPOSE 3000
|
|
86
|
+
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=6 \
|
|
87
|
+
CMD node -e "fetch('http://127.0.0.1:3000/health/ready').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
|
27
88
|
CMD ["node", "dist/main"]
|
|
@@ -1,17 +1,61 @@
|
|
|
1
1
|
# syntax=docker/dockerfile:1
|
|
2
|
+
|
|
3
|
+
# The npm that reads package-lock.json must be the major that wrote it. node:22
|
|
4
|
+
# bundles npm 10, and a lockfile written by npm 11 records platform-specific
|
|
5
|
+
# packages as extraneous with no `optional` flag; npm 10 rejects those with
|
|
6
|
+
# EBADPLATFORM instead of skipping them, so `npm ci` fails inside the image while
|
|
7
|
+
# `npm install` on the developer's machine is fine. Keep this in step with the npm
|
|
8
|
+
# the project is developed with.
|
|
9
|
+
ARG NPM_VERSION=11.17.0
|
|
10
|
+
|
|
11
|
+
# See apps/api/Dockerfile for why the workspace manifests are collected rather than
|
|
12
|
+
# listed: an absent workspace package.json does not fail `npm ci`, it silently
|
|
13
|
+
# changes the resolved dependency tree inside the image.
|
|
14
|
+
FROM node:22-alpine AS manifests
|
|
15
|
+
WORKDIR /src
|
|
16
|
+
COPY . .
|
|
17
|
+
RUN mkdir -p /app \
|
|
18
|
+
&& cp package.json package-lock.json /app/ \
|
|
19
|
+
&& find . -name package.json -not -path "*/node_modules/*" \
|
|
20
|
+
-exec sh -c 'mkdir -p "/app/$(dirname "$1")" && cp "$1" "/app/$1"' _ {} \;
|
|
21
|
+
|
|
2
22
|
FROM node:22-alpine AS deps
|
|
3
23
|
WORKDIR /app
|
|
4
|
-
COPY
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
COPY tests/package.json ./tests/package.json
|
|
24
|
+
COPY --from=manifests /app ./
|
|
25
|
+
ARG NPM_VERSION
|
|
26
|
+
RUN npm i -g "npm@${NPM_VERSION}"
|
|
8
27
|
RUN npm ci --no-audit --no-fund
|
|
9
28
|
|
|
29
|
+
FROM node:22-alpine AS prod-deps
|
|
30
|
+
WORKDIR /app
|
|
31
|
+
COPY --from=manifests /app ./
|
|
32
|
+
ARG NPM_VERSION
|
|
33
|
+
RUN npm i -g "npm@${NPM_VERSION}"
|
|
34
|
+
# The runtime tree for this image only.
|
|
35
|
+
#
|
|
36
|
+
# Scoping to the one workspace matters: --omit=dev at the root still installs the
|
|
37
|
+
# union of every workspace's production dependencies, so the API image carried Vite
|
|
38
|
+
# and the web image carried Nest. Measured on a generated project, this is 198
|
|
39
|
+
# packages instead of 283. TypeScript stays either way -- typeorm depends on ts-node,
|
|
40
|
+
# which depends on it, so it is a production dependency and not bloat.
|
|
41
|
+
RUN npm ci --omit=dev --no-audit --no-fund --workspace=apps/web --include-workspace-root
|
|
42
|
+
|
|
10
43
|
FROM node:22-alpine AS build
|
|
11
44
|
WORKDIR /app
|
|
12
|
-
COPY --from=deps /app
|
|
45
|
+
COPY --from=deps /app ./
|
|
13
46
|
COPY . .
|
|
47
|
+
RUN if [ -d packages ]; then \
|
|
48
|
+
for manifest in packages/*/package.json; do \
|
|
49
|
+
[ -e "$manifest" ] || continue; \
|
|
50
|
+
npm run build --if-present --workspace "$(dirname "$manifest")"; \
|
|
51
|
+
done; \
|
|
52
|
+
fi
|
|
14
53
|
RUN npm run build --workspace=apps/web
|
|
54
|
+
# The runtime needs the real workspace package directories, not just the symlinks
|
|
55
|
+
# npm leaves in node_modules pointing at them. Without this the image boots and
|
|
56
|
+
# dies on "Cannot find module <package>" -- a dangling link, at runtime only.
|
|
57
|
+
# mkdir keeps the COPY valid for a project that has no packages/ at all.
|
|
58
|
+
RUN mkdir -p /app/packages
|
|
15
59
|
|
|
16
60
|
FROM node:22-alpine AS runtime
|
|
17
61
|
WORKDIR /app
|
|
@@ -20,10 +64,18 @@ ENV NODE_ENV=production
|
|
|
20
64
|
# API can return stable validation codes after accounting for multipart framing.
|
|
21
65
|
ENV BODY_SIZE_LIMIT=3M
|
|
22
66
|
RUN addgroup -S -g 101 app && adduser -S -D -H -u 100 -G app app
|
|
23
|
-
COPY --from=
|
|
24
|
-
COPY --from=
|
|
67
|
+
COPY --from=prod-deps /app/node_modules ./node_modules
|
|
68
|
+
COPY --from=manifests /app/apps/web ./apps/web
|
|
69
|
+
COPY --from=build /app/packages ./packages
|
|
25
70
|
COPY --from=build /app/apps/web/build ./apps/web/build
|
|
71
|
+
# The entry and the modules it imports run OUTSIDE the bundle, so they ship as
|
|
72
|
+
# source. Without them the image falls back to an entry that cannot answer a
|
|
73
|
+
# WebSocket upgrade.
|
|
74
|
+
COPY --from=build /app/apps/web/server.js ./apps/web/server.js
|
|
75
|
+
COPY --from=build /app/apps/web/src/lib/server ./apps/web/src/lib/server
|
|
26
76
|
WORKDIR /app/apps/web
|
|
27
77
|
USER 100:101
|
|
28
78
|
EXPOSE 3000
|
|
29
|
-
|
|
79
|
+
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=6 \
|
|
80
|
+
CMD node -e "fetch('http://127.0.0.1:3000/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
|
81
|
+
CMD ["node", "server.js"]
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"build": "vite build",
|
|
9
9
|
"preview": "vite preview",
|
|
10
10
|
"lint": "svelte-check --tsconfig ./tsconfig.json",
|
|
11
|
-
"test": "
|
|
11
|
+
"test": "vitest run"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@internationalized/date": "^3.12.2",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"typesafe-i18n": "^5.26.2",
|
|
33
33
|
"typescript": "^6.0.3",
|
|
34
34
|
"vite": "^5.4.0",
|
|
35
|
+
"vitest": "^4.1.10",
|
|
35
36
|
"tw-animate-css": "^1.4.0"
|
|
36
37
|
}
|
|
37
38
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The production entry point: adapter-node's server plus WebSocket upgrades.
|
|
3
|
+
*
|
|
4
|
+
* `node build` — adapter-node's own entry — serves HTTP perfectly well and drops
|
|
5
|
+
* every WebSocket upgrade into SvelteKit, which cannot answer one. This entry keeps
|
|
6
|
+
* adapter-node in charge of listening, graceful shutdown, and the PORT/HOST/
|
|
7
|
+
* IDLE_TIMEOUT contract, and adds the one thing it cannot do.
|
|
8
|
+
*
|
|
9
|
+
* Nothing is proxied unless `WS_PROXY_PATHS` names a path, so a deployment that has
|
|
10
|
+
* no WebSocket behaves exactly as `node build` did.
|
|
11
|
+
*
|
|
12
|
+
* ⚠ Not type-checked: it imports `./build/`, which only exists after `npm run build`,
|
|
13
|
+
* and lint must not depend on a build. Everything with a decision in it lives in
|
|
14
|
+
* `src/lib/server/ws-upgrade.js`, which is unit-tested; this is wiring.
|
|
15
|
+
*/
|
|
16
|
+
import { server } from "./build/index.js";
|
|
17
|
+
import { createUpgradeProxy, relayPaths } from "./src/lib/server/ws-upgrade.js";
|
|
18
|
+
|
|
19
|
+
const allowed = relayPaths();
|
|
20
|
+
if (allowed.size > 0) {
|
|
21
|
+
// polka exposes the http.Server it was handed. If a future adapter stops doing so,
|
|
22
|
+
// fail at boot: serving silently without upgrades is the bug this entry exists for.
|
|
23
|
+
const httpServer = server.server;
|
|
24
|
+
if (!httpServer || typeof httpServer.on !== "function") {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"adapter-node did not expose its HTTP server; the WebSocket upgrade proxy cannot be mounted",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
httpServer.on("upgrade", createUpgradeProxy({ allowed }));
|
|
30
|
+
console.log(`Mounted WebSocket upgrade proxy for ${[...allowed].join(", ")}`);
|
|
31
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { RequestEvent } from "@sveltejs/kit";
|
|
2
2
|
import { createApiClient, type ApiClient } from "@podosoft/podokit-api-client";
|
|
3
|
-
import { backendBaseUrl,
|
|
3
|
+
import { backendBaseUrl, resolveClientIp } from "./backend-proxy";
|
|
4
4
|
|
|
5
5
|
// Server-side client for load functions and hooks: points at the internal
|
|
6
6
|
// backend URL and forwards the incoming request's cookies.
|
|
7
7
|
export function serverApiClient(event: RequestEvent): ApiClient {
|
|
8
8
|
const cookie = event.request.headers.get("cookie") ?? "";
|
|
9
|
-
const clientIp =
|
|
9
|
+
const clientIp = resolveClientIp(event.getClientAddress);
|
|
10
10
|
const fetchWithCookies: typeof fetch = (input, init) => {
|
|
11
11
|
const headers = new Headers(init?.headers);
|
|
12
12
|
if (cookie) headers.set("cookie", cookie);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { proxyRequest, resolveClientIp } from "./backend-proxy";
|
|
3
|
+
|
|
4
|
+
describe("backend proxy client address handling", () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.unstubAllGlobals();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns the normalized address from a trusted proxy chain", () => {
|
|
10
|
+
expect(resolveClientIp(() => "::ffff:203.0.113.9")).toBe("203.0.113.9");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("omits the address when the proxy header is absent", () => {
|
|
14
|
+
expect(
|
|
15
|
+
resolveClientIp(() => {
|
|
16
|
+
throw new Error("address header is absent");
|
|
17
|
+
}),
|
|
18
|
+
).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("omits the address when the proxy chain is too short", () => {
|
|
22
|
+
expect(
|
|
23
|
+
resolveClientIp(() => {
|
|
24
|
+
throw new Error("configured depth exceeds address count");
|
|
25
|
+
}),
|
|
26
|
+
).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("does not trust an incoming forwarded header as a fallback", async () => {
|
|
30
|
+
let upstreamHeaders: Headers | undefined;
|
|
31
|
+
vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
32
|
+
upstreamHeaders = new Headers(init?.headers);
|
|
33
|
+
return new Response(null, { status: 204 });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const request = new Request("http://app.localhost/api/health", {
|
|
37
|
+
headers: { "x-forwarded-for": "198.51.100.25" },
|
|
38
|
+
});
|
|
39
|
+
const response = await proxyRequest(request, "http://api:5002/health");
|
|
40
|
+
|
|
41
|
+
expect(response.status).toBe(204);
|
|
42
|
+
expect(upstreamHeaders?.get("x-forwarded-for")).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("forwards a resolved client address", async () => {
|
|
46
|
+
let upstreamHeaders: Headers | undefined;
|
|
47
|
+
vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
48
|
+
upstreamHeaders = new Headers(init?.headers);
|
|
49
|
+
return new Response(null, { status: 204 });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const request = new Request("http://app.localhost/api/health");
|
|
53
|
+
const response = await proxyRequest(request, "http://api:5002/health", "203.0.113.9");
|
|
54
|
+
|
|
55
|
+
expect(response.status).toBe(204);
|
|
56
|
+
expect(upstreamHeaders?.get("x-forwarded-for")).toBe("203.0.113.9");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Not forwarding this does not leave the header unset: fetch substitutes its own
|
|
60
|
+
// default, so the API records "node" for every session and the account page shows
|
|
61
|
+
// that as the device for all of them.
|
|
62
|
+
it("forwards the browser's user agent so sessions record the real device", async () => {
|
|
63
|
+
let upstreamHeaders: Headers | undefined;
|
|
64
|
+
vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
65
|
+
upstreamHeaders = new Headers(init?.headers);
|
|
66
|
+
return new Response(null, { status: 204 });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/141.0.0.0 Safari/537.36";
|
|
70
|
+
const request = new Request("http://app.localhost/api/auth/sign-in/email", {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "user-agent": agent },
|
|
73
|
+
});
|
|
74
|
+
const response = await proxyRequest(request, "http://api:5002/auth/sign-in/email");
|
|
75
|
+
|
|
76
|
+
expect(response.status).toBe(204);
|
|
77
|
+
expect(upstreamHeaders?.get("user-agent")).toBe(agent);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
// Server-side proxy boundary. The browser never talks to the API directly:
|
|
2
2
|
// requests go through SvelteKit server routes, which forward an allowlist of
|
|
3
3
|
// headers to BACKEND_INTERNAL_URL and relay the response (including Set-Cookie).
|
|
4
|
-
|
|
4
|
+
//
|
|
5
|
+
// `user-agent` is on the list for the same reason the client IP is forwarded
|
|
6
|
+
// below: better-auth records it on the session and the account page shows it as
|
|
7
|
+
// the device. Leaving it off does not produce an empty column -- fetch supplies
|
|
8
|
+
// its own default, so every session in the database reads "node", and the list
|
|
9
|
+
// of "your devices" becomes a list of identical rows that identify nothing.
|
|
10
|
+
const FORWARDED_HEADERS = [
|
|
11
|
+
"authorization",
|
|
12
|
+
"cookie",
|
|
13
|
+
"content-type",
|
|
14
|
+
"accept",
|
|
15
|
+
"origin",
|
|
16
|
+
"referer",
|
|
17
|
+
"user-agent",
|
|
18
|
+
];
|
|
5
19
|
const RELAYED_RESPONSE_HEADERS = ["content-type", "location", "cache-control"];
|
|
6
20
|
|
|
7
21
|
export function backendBaseUrl(): string {
|
|
@@ -19,6 +33,15 @@ export function normalizeClientIp(address: string | undefined): string | undefin
|
|
|
19
33
|
return address;
|
|
20
34
|
}
|
|
21
35
|
|
|
36
|
+
export function resolveClientIp(getClientAddress: () => string): string | undefined {
|
|
37
|
+
try {
|
|
38
|
+
return normalizeClientIp(getClientAddress());
|
|
39
|
+
} catch {
|
|
40
|
+
// An absent or shorter-than-configured proxy chain is not a trusted source.
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
22
45
|
export async function proxyRequest(
|
|
23
46
|
request: Request,
|
|
24
47
|
targetUrl: string,
|