@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.
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/docs/auth-validation.md +34 -0
- package/docs/auth.md +35 -0
- package/docs/deployment.md +49 -0
- package/docs/getting-started.md +62 -0
- package/docs/github-actions.md +421 -0
- package/docs/realtime.md +61 -0
- package/docs/release.md +64 -0
- package/docs/server.md +45 -0
- package/docs/settings.md +42 -0
- package/docs/sidebar.md +80 -0
- package/docs/ui-guidelines.md +55 -0
- package/package.json +60 -0
- package/src/build/build-binary.ts +42 -0
- package/src/build/index.ts +1 -0
- package/src/contracts/index.ts +86 -0
- package/src/server/auth/api-keys.ts +61 -0
- package/src/server/auth/crypto.ts +34 -0
- package/src/server/auth/device-auth.ts +324 -0
- package/src/server/auth/passkeys.ts +280 -0
- package/src/server/auth/request-origin.ts +33 -0
- package/src/server/auth/sqlite-store.ts +301 -0
- package/src/server/auth/store.ts +91 -0
- package/src/server/auth/types.ts +25 -0
- package/src/server/create-web-app-server.ts +447 -0
- package/src/server/index.ts +7 -0
- package/src/server/logger.ts +44 -0
- package/src/server/realtime/bus.ts +104 -0
- package/src/server/responses.ts +54 -0
- package/src/server/routes.ts +67 -0
- package/src/server/runtime-config.ts +101 -0
- package/src/server/same-origin.ts +35 -0
- package/src/types.d.ts +11 -0
- package/src/web/WebAppRoot.tsx +706 -0
- package/src/web/components/index.tsx +471 -0
- package/src/web/index.ts +5 -0
- package/src/web/realtime/useRealtime.ts +189 -0
- package/src/web/sidebar/types.ts +45 -0
- 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.
|