@pablozaiden/webapp 0.0.1

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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/docs/auth-validation.md +34 -0
  4. package/docs/auth.md +35 -0
  5. package/docs/deployment.md +49 -0
  6. package/docs/getting-started.md +62 -0
  7. package/docs/github-actions.md +421 -0
  8. package/docs/realtime.md +61 -0
  9. package/docs/release.md +64 -0
  10. package/docs/server.md +45 -0
  11. package/docs/settings.md +42 -0
  12. package/docs/sidebar.md +80 -0
  13. package/docs/ui-guidelines.md +55 -0
  14. package/package.json +60 -0
  15. package/src/build/build-binary.ts +42 -0
  16. package/src/build/index.ts +1 -0
  17. package/src/contracts/index.ts +86 -0
  18. package/src/server/auth/api-keys.ts +61 -0
  19. package/src/server/auth/crypto.ts +34 -0
  20. package/src/server/auth/device-auth.ts +324 -0
  21. package/src/server/auth/passkeys.ts +280 -0
  22. package/src/server/auth/request-origin.ts +33 -0
  23. package/src/server/auth/sqlite-store.ts +301 -0
  24. package/src/server/auth/store.ts +91 -0
  25. package/src/server/auth/types.ts +25 -0
  26. package/src/server/create-web-app-server.ts +447 -0
  27. package/src/server/index.ts +7 -0
  28. package/src/server/logger.ts +44 -0
  29. package/src/server/realtime/bus.ts +104 -0
  30. package/src/server/responses.ts +54 -0
  31. package/src/server/routes.ts +67 -0
  32. package/src/server/runtime-config.ts +101 -0
  33. package/src/server/same-origin.ts +35 -0
  34. package/src/types.d.ts +11 -0
  35. package/src/web/WebAppRoot.tsx +706 -0
  36. package/src/web/components/index.tsx +471 -0
  37. package/src/web/index.ts +5 -0
  38. package/src/web/realtime/useRealtime.ts +189 -0
  39. package/src/web/sidebar/types.ts +45 -0
  40. package/src/web/styles.css +1295 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pablo Zaidenvoren
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # @pablozaiden/webapp
2
+
3
+ Opinionated Bun + React framework for single-server TypeScript webapps: one Bun process serves the React UI, API routes, passkey auth, API keys, device auth, realtime websocket state, settings, binary builds and Docker images.
4
+
5
+ ## Quick start
6
+
7
+ Use one of the examples while the package is private:
8
+
9
+ ```bash
10
+ bun install
11
+ bun run dev:notes-todo
12
+ bun run dev:kitchen-sink
13
+ ```
14
+
15
+ Both examples run with Bun native hot reload through `bun --hot`, with no standalone frontend build server and no external `WEB_DIST_DIR`.
16
+
17
+ ## Main exports
18
+
19
+ | Export | Use |
20
+ | --- | --- |
21
+ | `@pablozaiden/webapp/server` | `createWebAppServer`, route helpers, responses, SQLite store |
22
+ | `@pablozaiden/webapp/web` | `WebAppRoot`, sidebar types, UI controls, realtime hooks |
23
+ | `@pablozaiden/webapp/contracts` | Shared auth/config/device/API-key types |
24
+ | `@pablozaiden/webapp/build` | Bun single-binary compile helper |
25
+
26
+ See `docs/getting-started.md` for the minimum app shape and `examples/notes-todo` for a realistic app. Use `docs/github-actions.md` when adding CI, Docker and release workflows to an app built with the framework. Use `bun run screenshots` for reproducible manual visual captures. Release/publishing details for this package are in `docs/release.md`.
@@ -0,0 +1,34 @@
1
+ # Auth validation checklist
2
+
3
+ Run this checklist before migrating an app or cutting a checkpoint.
4
+
5
+ ## Passkeys
6
+
7
+ 1. Start the app with passkeys enabled and an empty data dir.
8
+ 2. Confirm the bootstrap screen appears before the shell.
9
+ 3. Register a passkey with the browser/platform authenticator.
10
+ 4. Confirm the app shell loads and Settings shows passkey configured.
11
+ 5. Click Logout and confirm reload/login requires passkey authentication.
12
+ 6. Authenticate with the registered passkey and confirm the shell loads again.
13
+ 7. Delete passkey from Settings, confirm the dialog supports Escape, X and Cancel.
14
+ 8. Confirm deleting returns to bootstrap on reload.
15
+
16
+ ## API keys
17
+
18
+ 1. Create an API key from Settings and copy the token.
19
+ 2. Confirm the token is shown once.
20
+ 3. Call a protected API route with `Authorization: Bearer <token>`.
21
+ 4. Delete the key from Settings and confirm the confirmation dialog appears.
22
+ 5. Confirm the deleted token no longer authenticates.
23
+
24
+ ## Device auth
25
+
26
+ 1. Call `POST /api/auth/device` with `client_id` and `scope`.
27
+ 2. Open `verification_uri_complete` in the browser.
28
+ 3. Confirm the device approval screen shows client, scope, status and expiry.
29
+ 4. Approve the request and exchange `device_code` at `/api/auth/token`.
30
+ 5. Use the access token on a scoped route.
31
+ 6. Refresh with `/api/auth/refresh` and confirm a new refresh token is returned.
32
+ 7. Reuse the old refresh token and confirm `invalid_grant`.
33
+ 8. Reuse the consumed device code and confirm `invalid_grant`.
34
+ 9. Revoke the active device session from Settings and confirm refresh fails.
package/docs/auth.md ADDED
@@ -0,0 +1,35 @@
1
+ # Auth and security
2
+
3
+ The framework provides three auth modes designed to coexist.
4
+
5
+ ## Passkeys
6
+
7
+ Passkeys follow a single-passkey bootstrap model:
8
+
9
+ 1. If no passkey exists, registration is allowed.
10
+ 2. Once a passkey exists, protected routes require an authenticated passkey browser session, API key, or device bearer token.
11
+ 3. Additional public registration is rejected after bootstrap.
12
+
13
+ Settings owns passkey logout/delete UX.
14
+
15
+ ## API keys
16
+
17
+ API keys are bearer tokens for scripts and agents. They are stored hashed in SQLite and shown only once at creation. Route `scopes` are enforced for API-key requests; `*` grants all scopes.
18
+
19
+ Same-origin checks are skipped for API-key and device bearer requests unless a route sets `sameOrigin: "always"`, because non-browser clients usually do not send `Origin`.
20
+
21
+ ## Device auth
22
+
23
+ Device auth is included in V1:
24
+
25
+ 1. Client calls `POST /api/auth/device` with optional `client_id` and `scope`.
26
+ 2. Server returns `device_code`, human `user_code`, `verification_uri`, and polling interval.
27
+ 3. Browser user opens `/device?user_code=...` and approves from the same-origin UI.
28
+ 4. Client exchanges the approved code at `/api/auth/token`.
29
+ 5. Access tokens are JWT bearer tokens; refresh tokens rotate on every refresh.
30
+
31
+ Device codes are one-use. Reusing a consumed device code or stale refresh token returns `invalid_grant`.
32
+
33
+ ## Same-origin policy
34
+
35
+ Cookie/browser mutations require matching `Origin` or `Referer` by default. Use `sameOrigin: "never"` only for deliberate public/server-to-server routes; use `sameOrigin: "always"` for especially sensitive endpoints even when bearer tokens are used.
@@ -0,0 +1,49 @@
1
+ # Deployment
2
+
3
+ ## Single binary
4
+
5
+ Use `buildWebAppBinary` from `@pablozaiden/webapp/build`.
6
+
7
+ ```ts
8
+ import { buildWebAppBinary, getBunCompileTargetFromArgs } from "@pablozaiden/webapp/build";
9
+
10
+ await buildWebAppBinary({
11
+ entrypoint: import.meta.resolve("./src/index.ts").replace("file://", ""),
12
+ outfile: "./dist/my-app",
13
+ target: getBunCompileTargetFromArgs(),
14
+ define: {
15
+ WEBAPP_VERSION: JSON.stringify(process.env["WEBAPP_VERSION"] ?? "0.0.0-development"),
16
+ },
17
+ });
18
+ ```
19
+
20
+ Run the binary with the same CLI contract:
21
+
22
+ ```bash
23
+ MY_APP_PORT=3300 ./dist/my-app serve
24
+ ./dist/my-app version
25
+ ```
26
+
27
+ ## Docker
28
+
29
+ Examples include Dockerfiles that copy a Bun-compiled Linux binary into a runtime image. Build the binary for the container architecture first, then build the image:
30
+
31
+ ```bash
32
+ bun run --cwd examples/notes-todo build --target=bun-linux-arm64
33
+ docker build -f examples/notes-todo/Dockerfile -t webapp-notes-todo:local .
34
+
35
+ bun run --cwd examples/kitchen-sink build --target=bun-linux-arm64
36
+ docker build -f examples/kitchen-sink/Dockerfile -t webapp-kitchen-sink:local .
37
+ ```
38
+
39
+ The example Dockerfiles default to `node:22-bookworm` as a readily available Linux runtime base because the app itself is already a self-contained Bun binary. Use `--target=bun-linux-x64` and pass `--build-arg APP_BINARY=examples/notes-todo/dist/notes-todo-linux-x64` when building an x64 container from a different host. The container should set:
40
+
41
+ ```dockerfile
42
+ ENV MY_APP_HOST=0.0.0.0
43
+ ENV MY_APP_PORT=3000
44
+ ENV MY_APP_DATA_DIR=/data
45
+ ```
46
+
47
+ The framework has no external frontend asset directory, so the compiled binary contains the server and Bun HTML import graph.
48
+
49
+ For full application CI/CD templates, including a production Dockerfile, PR checks, GHCR publishing on `main`, binary releases and Docker release images, see `docs/github-actions.md`.
@@ -0,0 +1,62 @@
1
+ # Getting started
2
+
3
+ Apps are one Bun application: backend routes, websocket, React UI and static assets are served by the same server. The framework does not support or require a standalone client dist directory.
4
+
5
+ ```ts
6
+ import webIndex from "./index.html";
7
+ import { createWebAppServer, defineRoutes, jsonResponse } from "@pablozaiden/webapp/server";
8
+
9
+ const routes = defineRoutes({
10
+ "/api/items": {
11
+ GET: () => jsonResponse([{ id: "one", title: "One" }]),
12
+ },
13
+ "/api/webhooks/:source/:token": {
14
+ auth: "public",
15
+ sameOrigin: "never",
16
+ POST: () => jsonResponse({ ok: true }),
17
+ },
18
+ });
19
+
20
+ const app = createWebAppServer({
21
+ appName: "My App",
22
+ envPrefix: "MY_APP",
23
+ index: webIndex,
24
+ auth: { passkeys: true, apiKeys: true, deviceAuth: true },
25
+ realtime: { path: "/api/ws" },
26
+ routes,
27
+ });
28
+
29
+ await app.runFromCli();
30
+ ```
31
+
32
+ Frontend entrypoints should import the framework CSS explicitly so Bun hot reload observes style changes:
33
+
34
+ ```ts
35
+ import { WebAppRoot } from "@pablozaiden/webapp/web";
36
+ import "@pablozaiden/webapp/web/styles.css";
37
+ ```
38
+
39
+ Recommended dev script:
40
+
41
+ ```json
42
+ {
43
+ "scripts": {
44
+ "dev": "bun --hot src/index.ts serve"
45
+ }
46
+ }
47
+ ```
48
+
49
+ The app configures an uppercase `envPrefix`; the framework reads only variables under that prefix:
50
+
51
+ | Variable | Default | Description |
52
+ | --- | --- | --- |
53
+ | `{PREFIX}_HOST` | `127.0.0.1` | Bind host |
54
+ | `{PREFIX}_PORT` | `3000` | Bind port |
55
+ | `{PREFIX}_DATA_DIR` | `./data` | SQLite persistence directory |
56
+ | `{PREFIX}_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error`; locks settings log-level control when set |
57
+ | `{PREFIX}_DISABLE_PASSKEY` | unset | Development escape hatch |
58
+ | `{PREFIX}_DISABLE_SAME_ORIGIN_CHECK` | unset | Development/testing escape hatch |
59
+ | `{PREFIX}_PUBLIC_BASE_URL` | request origin | External URL for device auth links |
60
+ | `{PREFIX}_AUTH_ISSUER` | `urn:{prefix}:webapp` | JWT issuer override |
61
+
62
+ For CI/CD setup in apps built with the framework, use `docs/github-actions.md`. It includes copy-paste GitHub Actions and a production Dockerfile template.
@@ -0,0 +1,421 @@
1
+ # GitHub Actions for framework apps
2
+
3
+ Use these templates for applications built with `@pablozaiden/webapp`. They follow the same deployment pattern as the reference apps:
4
+
5
+ 1. Pull requests install, build, test and smoke-test the Bun dev server.
6
+ 2. Merges to `main` publish a `main` Docker image to GHCR.
7
+ 3. Published GitHub releases upload standalone binaries.
8
+ 4. Published GitHub releases publish semver Docker images to GHCR.
9
+
10
+ Replace these placeholders before committing:
11
+
12
+ | Placeholder | Example | Meaning |
13
+ | --- | --- | --- |
14
+ | `my-app` | `notes` | Binary name, Docker user/group and local container name |
15
+ | `MY_APP` | `NOTES` | `envPrefix` used by `createWebAppServer` |
16
+ | `My App` | `Notes` | Human-readable app name |
17
+ | `src/build.ts` | `apps/server/src/build.ts` | Build script path |
18
+ | `dist/my-app` | `apps/server/dist/my-app` | Default binary path produced by `bun run build` |
19
+
20
+ The templates assume these package scripts:
21
+
22
+ ```json
23
+ {
24
+ "scripts": {
25
+ "dev": "bun --hot src/index.ts serve",
26
+ "build": "bun src/build.ts",
27
+ "test": "bun test"
28
+ }
29
+ }
30
+ ```
31
+
32
+ If the app uses TypeScript typechecking separately, add a `tsc` script and call it from the PR workflow before tests.
33
+
34
+ ## Dockerfile
35
+
36
+ Place this at `Dockerfile` in the app repository root. It builds the app inside Docker, copies only the standalone Bun binary into a slim runtime image, runs as a non-root user, and exposes `/api/health` as the container healthcheck.
37
+
38
+ ```dockerfile
39
+ FROM oven/bun:1 AS builder
40
+ WORKDIR /app
41
+ COPY . .
42
+ RUN bun install --frozen-lockfile
43
+ RUN bun run build
44
+
45
+ FROM debian:bookworm-slim
46
+ WORKDIR /app
47
+ RUN apt-get update && apt-get install -y --no-install-recommends \
48
+ ca-certificates \
49
+ curl \
50
+ tini \
51
+ && rm -rf /var/lib/apt/lists/*
52
+
53
+ COPY --from=builder /app/dist/my-app /app/my-app
54
+
55
+ RUN groupadd --system my-app && \
56
+ useradd --system --gid my-app --no-create-home my-app
57
+ RUN mkdir -p /app/data && chown -R my-app:my-app /app/data
58
+
59
+ ENV NODE_ENV=production
60
+ ENV MY_APP_HOST=0.0.0.0
61
+ ENV MY_APP_PORT=8080
62
+ ENV MY_APP_DATA_DIR=/app/data
63
+
64
+ EXPOSE 8080
65
+ USER my-app
66
+
67
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
68
+ CMD curl -f http://localhost:${MY_APP_PORT}/api/health || exit 1
69
+
70
+ ENTRYPOINT ["/usr/bin/tini", "--"]
71
+ CMD ["/app/my-app", "serve"]
72
+ ```
73
+
74
+ For apps that need extra OS packages, add them to the runtime `apt-get install` list. Keep `ca-certificates`, `curl` and `tini`.
75
+
76
+ ## Pull request workflow
77
+
78
+ Place this at `.github/workflows/pr.yml`.
79
+
80
+ The smoke job starts the native Bun hot-reload dev server, checks `/api/health`, validates that `/` returns HTML, and fetches the first JavaScript and CSS assets referenced by the page. Passkeys and same-origin checks are disabled only for CI startup smoke tests.
81
+
82
+ ```yaml
83
+ name: Pull Request
84
+
85
+ on:
86
+ pull_request:
87
+ branches: [main]
88
+
89
+ jobs:
90
+ build-and-test:
91
+ runs-on: ubuntu-latest
92
+ permissions:
93
+ contents: read
94
+ steps:
95
+ - name: Checkout repository
96
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
97
+
98
+ - name: Setup Bun
99
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
100
+ with:
101
+ bun-version: latest
102
+
103
+ - name: Install dependencies
104
+ run: bun install --frozen-lockfile
105
+
106
+ - name: Build
107
+ run: bun run build
108
+
109
+ - name: Run tests
110
+ run: bun run test
111
+
112
+ smoke-test:
113
+ runs-on: ubuntu-latest
114
+ permissions:
115
+ contents: read
116
+ steps:
117
+ - name: Checkout repository
118
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
119
+
120
+ - name: Setup Bun
121
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
122
+ with:
123
+ bun-version: latest
124
+
125
+ - name: Install dependencies
126
+ run: bun install --frozen-lockfile
127
+
128
+ - name: Smoke test app startup
129
+ shell: bash
130
+ run: |
131
+ set -euo pipefail
132
+ MY_APP_DISABLE_PASSKEY=true \
133
+ MY_APP_DISABLE_SAME_ORIGIN_CHECK=true \
134
+ MY_APP_PORT=3018 \
135
+ MY_APP_DATA_DIR="$(mktemp -d)" \
136
+ bun run dev > /tmp/my-app-pr-smoke.log 2>&1 &
137
+ app_pid="$!"
138
+ trap 'kill "$app_pid" 2>/dev/null || true' EXIT
139
+
140
+ for _ in {1..60}; do
141
+ if curl -fsS http://127.0.0.1:3018/api/health >/dev/null; then
142
+ break
143
+ fi
144
+ if ! kill -0 "$app_pid" 2>/dev/null; then
145
+ cat /tmp/my-app-pr-smoke.log
146
+ exit 1
147
+ fi
148
+ sleep 0.5
149
+ done
150
+ curl -fsS http://127.0.0.1:3018/api/health >/dev/null
151
+
152
+ html="$(curl -fsS -D /tmp/my-app-root-headers http://127.0.0.1:3018/)"
153
+ grep -qi '^content-type: text/html' /tmp/my-app-root-headers
154
+
155
+ js="$(printf '%s' "$html" | sed -n 's/.*src="\([^"]*\.js\)".*/\1/p' | head -n1)"
156
+ css="$(printf '%s' "$html" | sed -n 's/.*href="\([^"]*\.css\)".*/\1/p' | head -n1)"
157
+ test -n "$js"
158
+ test -n "$css"
159
+ js="${js#./}"; js="${js#/}"
160
+ css="${css#./}"; css="${css#/}"
161
+
162
+ curl -fsS -D /tmp/my-app-js-headers "http://127.0.0.1:3018/$js" >/dev/null
163
+ curl -fsS -D /tmp/my-app-css-headers "http://127.0.0.1:3018/$css" >/dev/null
164
+ grep -qi '^content-type: .*javascript' /tmp/my-app-js-headers
165
+ grep -qi '^content-type: text/css' /tmp/my-app-css-headers
166
+
167
+ - name: Smoke test Docker image
168
+ shell: bash
169
+ run: |
170
+ set -euo pipefail
171
+ docker build -t my-app-pr-smoke .
172
+ docker run --rm -d --name my-app-pr-smoke -p 3019:8080 \
173
+ -e MY_APP_DISABLE_PASSKEY=true \
174
+ -e MY_APP_DISABLE_SAME_ORIGIN_CHECK=true \
175
+ my-app-pr-smoke
176
+ trap 'docker logs my-app-pr-smoke || true; docker stop my-app-pr-smoke >/dev/null 2>&1 || true' EXIT
177
+
178
+ for _ in {1..60}; do
179
+ if curl -fsS http://127.0.0.1:3019/api/health >/dev/null; then
180
+ break
181
+ fi
182
+ sleep 0.5
183
+ done
184
+ curl -fsS http://127.0.0.1:3019/api/health >/dev/null
185
+
186
+ html="$(curl -fsS -D /tmp/my-app-docker-root-headers http://127.0.0.1:3019/)"
187
+ grep -qi '^content-type: text/html' /tmp/my-app-docker-root-headers
188
+
189
+ js="$(printf '%s' "$html" | sed -n 's/.*src="\([^"]*\.js\)".*/\1/p' | head -n1)"
190
+ css="$(printf '%s' "$html" | sed -n 's/.*href="\([^"]*\.css\)".*/\1/p' | head -n1)"
191
+ test -n "$js"
192
+ test -n "$css"
193
+ js="${js#./}"; js="${js#/}"
194
+ css="${css#./}"; css="${css#/}"
195
+
196
+ curl -fsS -D /tmp/my-app-docker-js-headers "http://127.0.0.1:3019/$js" >/dev/null
197
+ curl -fsS -D /tmp/my-app-docker-css-headers "http://127.0.0.1:3019/$css" >/dev/null
198
+ grep -qi '^content-type: .*javascript' /tmp/my-app-docker-js-headers
199
+ grep -qi '^content-type: text/css' /tmp/my-app-docker-css-headers
200
+ ```
201
+
202
+ Add route-specific checks after the asset checks, for example a public ping endpoint or a readonly API that works with passkeys disabled.
203
+
204
+ ## Main branch Docker workflow
205
+
206
+ Place this at `.github/workflows/docker-main.yml`.
207
+
208
+ It publishes `ghcr.io/<owner>/<repo>:main` after merges to `main`, updates `package.json` to a pre-release version based on the latest GitHub release, and runs a container health smoke test.
209
+
210
+ ```yaml
211
+ name: Docker Main
212
+
213
+ on:
214
+ push:
215
+ branches: [main]
216
+
217
+ jobs:
218
+ docker:
219
+ runs-on: ubuntu-latest
220
+ permissions:
221
+ contents: read
222
+ packages: write
223
+ steps:
224
+ - name: Checkout repository
225
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
226
+
227
+ - name: Get latest release version
228
+ id: latest-release
229
+ env:
230
+ GH_TOKEN: ${{ github.token }}
231
+ run: |
232
+ latest_tag="$(gh release view --json tagName -q '.tagName' 2>/dev/null || echo "v0.0.0")"
233
+ latest_version="${latest_tag#v}"
234
+ echo "version=${latest_version}" >> "$GITHUB_OUTPUT"
235
+
236
+ - name: Update package.json version
237
+ run: |
238
+ timestamp="$(date -u +"%Y-%m-%d-%H-%M")"
239
+ short_sha="${GITHUB_SHA::7}"
240
+ version="${{ steps.latest-release.outputs.version }}-main-${timestamp}-${short_sha}"
241
+ jq --arg v "$version" '.version = $v' package.json > package.json.tmp
242
+ mv package.json.tmp package.json
243
+
244
+ - name: Set up Docker Buildx
245
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
246
+
247
+ - name: Log in to Container Registry
248
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
249
+ with:
250
+ registry: ghcr.io
251
+ username: ${{ github.actor }}
252
+ password: ${{ secrets.GITHUB_TOKEN }}
253
+
254
+ - name: Normalize image name
255
+ id: image
256
+ run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
257
+
258
+ - name: Build and push Docker image
259
+ uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
260
+ with:
261
+ context: .
262
+ push: true
263
+ load: true
264
+ tags: ${{ steps.image.outputs.name }}:main
265
+ cache-from: type=gha
266
+ cache-to: type=gha,mode=max
267
+ platforms: linux/amd64
268
+
269
+ - name: Test container healthcheck
270
+ shell: bash
271
+ run: |
272
+ set -euo pipefail
273
+ docker run -d --name my-app-main-smoke -p 8080:8080 \
274
+ -e MY_APP_DISABLE_PASSKEY=true \
275
+ -e MY_APP_DISABLE_SAME_ORIGIN_CHECK=true \
276
+ ${{ steps.image.outputs.name }}:main
277
+ trap 'docker logs my-app-main-smoke || true; docker rm -f my-app-main-smoke >/dev/null 2>&1 || true' EXIT
278
+
279
+ for _ in {1..30}; do
280
+ status="$(docker inspect --format='{{.State.Health.Status}}' my-app-main-smoke 2>/dev/null || echo starting)"
281
+ if [ "$status" = "healthy" ]; then
282
+ break
283
+ fi
284
+ sleep 2
285
+ done
286
+
287
+ test "$(docker inspect --format='{{.State.Health.Status}}' my-app-main-smoke)" = "healthy"
288
+ curl -fsS http://127.0.0.1:8080/api/health >/dev/null
289
+ ```
290
+
291
+ ## Binary release workflow
292
+
293
+ Place this at `.github/workflows/binary-release.yml`.
294
+
295
+ It delegates cross-platform binary builds to the reusable binary release workflow and uploads checksummed assets to the GitHub release.
296
+
297
+ ```yaml
298
+ name: Binary Release
299
+
300
+ on:
301
+ release:
302
+ types: [published]
303
+
304
+ jobs:
305
+ binaries:
306
+ uses: pablozaiden/installer/.github/workflows/reusable-binary-release.yml@2e28bc9fbf69385ac5172e526a1268f72db02363
307
+ permissions:
308
+ contents: write
309
+ with:
310
+ prebuild_command: bun run build
311
+ binaries: |
312
+ [
313
+ {
314
+ "name": "my-app",
315
+ "asset_prefix": "my-app",
316
+ "build_command": "bun src/build.ts --target=$BUN_TARGET",
317
+ "output_path": "dist/my-app-$RELEASE_TARGET"
318
+ }
319
+ ]
320
+ ```
321
+
322
+ If `bun run build` already builds a local default binary and your `src/build.ts` supports `--target`, keep `prebuild_command: bun run build` to validate the app before producing release assets.
323
+
324
+ ## Docker release workflow
325
+
326
+ Place this at `.github/workflows/docker-release.yml`.
327
+
328
+ It publishes versioned GHCR images when a GitHub release is published:
329
+
330
+ ```yaml
331
+ name: Docker Release
332
+
333
+ on:
334
+ release:
335
+ types: [published]
336
+
337
+ jobs:
338
+ docker:
339
+ runs-on: ubuntu-latest
340
+ permissions:
341
+ contents: read
342
+ packages: write
343
+ steps:
344
+ - name: Checkout repository
345
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
346
+
347
+ - name: Update package.json version
348
+ run: |
349
+ version="${GITHUB_REF_NAME#v}"
350
+ jq --arg v "$version" '.version = $v' package.json > package.json.tmp
351
+ mv package.json.tmp package.json
352
+
353
+ - name: Set up QEMU
354
+ uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3
355
+
356
+ - name: Set up Docker Buildx
357
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
358
+
359
+ - name: Log in to Container Registry
360
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
361
+ with:
362
+ registry: ghcr.io
363
+ username: ${{ github.actor }}
364
+ password: ${{ secrets.GITHUB_TOKEN }}
365
+
366
+ - name: Normalize image name
367
+ id: image
368
+ run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
369
+
370
+ - name: Extract metadata
371
+ id: meta
372
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
373
+ with:
374
+ images: ${{ steps.image.outputs.name }}
375
+ tags: |
376
+ type=semver,pattern={{version}}
377
+ type=semver,pattern={{major}}.{{minor}}
378
+ type=semver,pattern={{major}}
379
+ type=raw,value=latest
380
+
381
+ - name: Build and push Docker image
382
+ uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
383
+ with:
384
+ context: .
385
+ push: true
386
+ tags: ${{ steps.meta.outputs.tags }}
387
+ labels: ${{ steps.meta.outputs.labels }}
388
+ cache-from: type=gha
389
+ cache-to: type=gha,mode=max
390
+ platforms: linux/amd64,linux/arm64
391
+ ```
392
+
393
+ ## Build script for release assets
394
+
395
+ The binary release workflow expects the app build script to accept `--target` and include the target suffix in the output filename. A minimal `src/build.ts`:
396
+
397
+ ```ts
398
+ import { buildWebAppBinary, getBunCompileTargetFromArgs } from "@pablozaiden/webapp/build";
399
+
400
+ const target = getBunCompileTargetFromArgs();
401
+ const suffix = target ? `-${target.replace("bun-", "")}` : "";
402
+
403
+ await buildWebAppBinary({
404
+ entrypoint: "src/index.ts",
405
+ outfile: `dist/my-app${suffix}`,
406
+ target,
407
+ define: {
408
+ WEBAPP_VERSION: JSON.stringify(process.env["WEBAPP_VERSION"] ?? "0.0.0-development"),
409
+ },
410
+ });
411
+ ```
412
+
413
+ ## Adaptation checklist
414
+
415
+ 1. Replace `my-app` everywhere with the binary name.
416
+ 2. Replace `MY_APP` everywhere with the app `envPrefix`.
417
+ 3. Make sure `createWebAppServer({ envPrefix: "MY_APP" })` matches the workflow and Dockerfile env vars.
418
+ 4. Keep `/api/health` available; it is provided by the framework server.
419
+ 5. Add app-specific smoke checks after the generic health and asset checks.
420
+ 6. Keep passkey/same-origin disable flags only in CI smoke jobs, not production deploys.
421
+ 7. For monorepos, update `src/build.ts`, `dist/my-app` and build commands to the app package path.