@thoughtpivot/flight 1.1.0 → 2.0.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ ## 2.0.0
4
+
5
+ ### Breaking
6
+
7
+ - **Production middleware order** when `FLIGHT_MODE` / `mode` is `production` **and** built-assets mode is on (`--disable_vite` or `FLIGHT_DISABLE_VITE` of `true` / `1` / `yes`): Flight now uses the **production SPA pipeline** by default. Static files from `FLIGHT_DIST_PATH` (default `../dist`) and the SPA `index.html` fallback run **immediately after** the Koa `router` (your `**/*.backend.ts` routes), **before** `koa-compress`, Redis-backed rate limiting, and optional response caching.
8
+ - **`koa-cash` is off by default** in that SPA pipeline (to avoid caching HTML or mixed `vary` surprises). Enable with **`FLIGHT_HTTP_CACHE=true`** (or `1` / `yes`) if you want the previous Redis-backed cache layer in that configuration.
9
+ - **Legacy stack** (previous order: `compress` → `ratelimit` → `koa-cash` → `koa-static` after the router) remains when **either** Vite is **not** disabled in production **or** you set **`FLIGHT_DISABLE_SPA_PIPELINE=true`** (or `1` / `yes`).
10
+
11
+ ### Added
12
+
13
+ - **Yargs CLI parity** for **`--mode`**, **`--port`**, **`--app_home`**, **`--app_key`**, **`--app_secret`**, **`--payload_limit`**, and **`--disable_vite`** (plus kebab-case aliases), so documented flags populate `argv` reliably instead of being dropped by the parser.
14
+ - **`FLIGHT_TRUST_PROXY`**: when `true` / `1` / `yes`, sets `app.proxy = true` so `ctx.ip` and rate-limit identity honor `X-Forwarded-For` behind a reverse proxy or load balancer.
15
+ - **`FLIGHT_STATIC_PREFIXES`**: comma-separated URL path prefixes (default `/assets,/fonts`) used to build the **rate-limit skip** list for `GET`/`HEAD` (hashed assets should not burn the API limiter).
16
+ - **`FLIGHT_RATE_LIMIT_EXCLUDE_PREFIXES`**: extra comma-separated prefixes merged into that skip list.
17
+ - **`FLIGHT_SPA_INDEX`**: path to the SPA shell inside the dist root (default `index.html`).
18
+ - **`FLIGHT_SPA_DENY_PREFIXES`**: extra comma-separated path prefixes that never receive the SPA HTML fallback (always merged with `/api` and `/health`).
19
+ - **`npm test`**: regression tests for the SPA pipeline (assets, deep links, API prefix, file-like paths).
20
+ - **`files` field** in `package.json` so publishes include only `dist/flight.js`, `dist/spa-pipeline.js`, and docs assets.
21
+
22
+ ### Notes
23
+
24
+ - `FLIGHT_DISABLE_VITE` accepts **`true`**, **`1`**, or **`yes`** (case-sensitive values as implemented for the string checks).
package/README.md CHANGED
@@ -1,23 +1,24 @@
1
+ <p align="center">
2
+ <img src="readme-assets/thoughtpivot-logo.svg" alt="ThoughtPivot" width="280" />
3
+ </p>
4
+
1
5
  # Flight
2
6
 
3
- Flight is a modern, high-performance web application server built on Node.js, designed for building scalable, component-driven applications. It is the successor to [Avian](https://github.com/ispyhumanfly/avian): originally created by ThoughtPivot, handed to FlyPaper Technologies, LLC, where Avian matured—Flight builds on those advancements with modern tooling and developer experience.
7
+ **Flight** is a Node.js application server for teams who want something **fast**, **boring in the good way**, and **ready for serious traffic**. You bring your own hosting—there is no lock-in to a proprietary edge or a single vendor’s deployment story. It fits **twelve-factor** style workflows: configuration via environment variables, horizontal scaling, and state kept where it belongs (for Flight, that includes **Redis** for sessions and cache-friendly layers).
4
8
 
5
- ## Overview
9
+ Think **platform-agnostic**: not framework-as-a-platform, but a clear runtime you can run wherever Node runs—VMs, Kubernetes, bare metal, your cloud of choice. Flight is aimed at **hyperscale-friendly** designs (cluster workers out of the box), **ephemeral** processes, and **component-shaped** backends so routes stay colocated with the features they serve. **Vue** and **Vite** are first-class today; **React** support is on the roadmap.
6
10
 
7
- Flight takes the best concepts from Avian and enhances them with modern tooling and practices. It provides a robust foundation for building enterprise-grade applications while maintaining simplicity and developer productivity.
11
+ Flight is **open source** from **[ThoughtPivot](https://github.com/thoughtpivot)**.
8
12
 
9
- ### Key Features
13
+ ## Highlights
10
14
 
11
- - **Modern Stack**: Built on Koa.js with TypeScript support
12
- - **Component-Based Architecture**: Organize your application into reusable components
13
- - **Built-in Development Server**: Powered by Vite for lightning-fast development
14
- - **Production-Ready**: Includes rate limiting, compression, and caching out of the box
15
- - **Cluster Mode**: Automatic load balancing across CPU cores
16
- - **Redis Integration**: Built-in support for session management and caching
17
- - **TypeScript First**: Native TypeScript support throughout the framework
18
- - **Hot Module Replacement**: Fast development with instant updates
19
- - **CORS Enabled**: Ready for modern web applications
20
- - **Security Features**: Rate limiting, secure session handling, and more
15
+ - **Performance-focused**: Cluster mode, compression, Redis-backed caching hooks, rate limiting in production
16
+ - **Developer velocity**: Vite-powered dev server with HMR for **Vue** (React roadmap)
17
+ - **Composable backends**: Discover `**/*.backend.ts` under your app root and mount Koa routes per component
18
+ - **Production SPA**: Built-in **`dist` + `index.html`** fallback (option B) when running **`production`** with **`disable_vite`**, with an explicit opt-out for API-only processes
19
+ - **Configurable discovery**: `--exclude_paths` / `FLIGHT_EXCLUDE_PATHS` to skip directories when scanning backends
20
+ - **TypeScript-native**: Written for TS projects; sensible defaults, minimal ceremony
21
+ - **Interop-friendly**: Correct handling of `yargs` when launched via **tsx** or similar loaders (no patch-package needed from **v1.0.8** onward)
21
22
 
22
23
  ## Installation
23
24
 
@@ -27,11 +28,11 @@ npm install @thoughtpivot/flight
27
28
  yarn add @thoughtpivot/flight
28
29
  ```
29
30
 
30
- Previously published on npm as `@spytech/flight`; switch your dependency to `@thoughtpivot/flight`.
31
+ Legacy npm scope: the package was previously published as `@spytech/flight`—use `@thoughtpivot/flight` going forward.
31
32
 
32
- ### Downstream apps (tsx / patch-package)
33
+ ### Downstream apps (tsx)
33
34
 
34
- From **v1.0.8** onward, Flight normalizes `require('yargs/yargs')` when loaders such as **tsx** expose it as `{ default: factory }` instead of the factory. If you only added **patch-package** for that workaround, upgrade `@thoughtpivot/flight`, delete `patches/@thoughtpivot+flight+*.patch`, and remove any **postinstall** hook that existed solely to apply it.
35
+ From **v1.0.8** onward, Flight normalizes `require('yargs/yargs')` under loaders that expose `{ default: factory }`. If you added **patch-package** only for that issue, upgrade Flight and drop that patch.
35
36
 
36
37
  ## Quick Start
37
38
 
@@ -49,13 +50,7 @@ npm init -y
49
50
  npm install @thoughtpivot/flight ioredis
50
51
  ```
51
52
 
52
- 3. Ensure Redis is running locally or set environment variables:
53
-
54
- ```bash
55
- # Default values shown below
56
- export FLIGHT_REDIS_HOST=localhost
57
- export FLIGHT_REDIS_PORT=6379
58
- ```
53
+ 3. Ensure Redis is running locally or set environment variables (see table below).
59
54
 
60
55
  4. Create a component with a backend route:
61
56
 
@@ -77,50 +72,130 @@ router.get('/hello', async (ctx) => {
77
72
  export default router.routes()
78
73
  ```
79
74
 
80
- 5. Start the server:
75
+ 5. Add scripts to **`package.json`** (the `flight` binary comes from **`node_modules/.bin`** after install):
76
+
77
+ ```json
78
+ {
79
+ "scripts": {
80
+ "dev": "flight --mode development",
81
+ "start": "flight --mode production"
82
+ }
83
+ }
84
+ ```
85
+
86
+ Use **`FLIGHT_DISABLE_VITE`**, **`FLIGHT_DIST_PATH`**, and related env vars in **`.env`** for production built-assets mode (see the configuration table below).
81
87
 
82
- Development mode:
88
+ 6. Start the server:
89
+
90
+ **Development (Vite on 3001, API on 3000):**
91
+
92
+ ```bash
93
+ npx flight --mode development
94
+ # or: npm run dev
95
+ ```
96
+
97
+ **Production:**
83
98
 
84
99
  ```bash
85
- node flight.js --mode development --app_home .
86
- # Starts development server on port 3001 with HMR
87
- # Backend API available on port 3000
100
+ npx flight --mode production
101
+ # or: npm run start
88
102
  ```
89
103
 
90
- Production mode:
104
+ **`--app_home`** defaults to **`.`** (the current working directory). Pass **`--app_home path/to/app`** only when your app root is not the directory you run the command from.
105
+
106
+ **Try without adding a dependency** (downloads the package for this invocation):
91
107
 
92
108
  ```bash
93
- node flight.js --mode production --app_home .
94
- # Builds and serves application on port 3000
109
+ npx --yes --package @thoughtpivot/flight flight --mode development
95
110
  ```
96
111
 
97
- Available CLI options:
112
+ **Global install** (optional): `npm install -g @thoughtpivot/flight`, then run **`flight`** from your PATH the same way as **`npx flight`**.
98
113
 
99
- - `--app_home`: Application root directory (default: current directory)
100
- - `--exclude_paths` / `--exclude-paths`: Directories under `app_home` to omit when discovering `**/*.backend.ts` routes. Repeat the flag (`--exclude_paths node_modules --exclude_paths dist`) or use commas (`--exclude_paths node_modules,dist`). Also accepts `FLIGHT_EXCLUDE_PATHS` (comma-separated). Paths must stay inside `app_home`.
101
- - `--app_key`: Application key for sessions (default: 'flightApp')
102
- - `--app_secret`: Secret key for session encryption (default: 'the best secret key in the world')
103
- - `--mode`: 'development' or 'production' (default: 'production')
114
+ ## Development vs production
115
+
116
+ Flight picks **`mode`** as: **`FLIGHT_MODE`** (if set and non-empty), else **`--mode`** from argv, else **`production`**. Everything below assumes Redis is reachable unless you only use routes that avoid session/ratelimit/cache.
117
+
118
+ | Topic | Development | Production |
119
+ | ---------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
120
+ | **Vite** | Child process: `npx vite --port 3001 --host 0.0.0.0` (HMR). | If **`disable_vite` is false**: `exec('npx vite build')` is triggered once at worker startup. If **`disable_vite` is true**: no Vite build. |
121
+ | **Listen port** | API + sessions on **`--port`** / `FLIGHT_PORT` (default **3000**). UI dev server on **3001**. | Same **`--port`** / `FLIGHT_PORT` for the worker. |
122
+ | **Static / SPA** | The Vite dev server serves UI; Flight does **not** mount the production static/SPA stack. | See **Production SPA pipeline** below: when **`production`** + **`disable_vite`**, Flight uses **Option A** by default unless opted out. |
123
+ | **Typical use** | Local full-stack with HMR. | CI-built `dist` served by Flight (or behind a load balancer). |
124
+
125
+ **Edge cases**
126
+
127
+ - **`production` + `disable_vite` false**: Vite build still runs; **legacy** middleware order applies (`compress` → `ratelimit` → `koa-cash` → static), because built-assets-only mode is off.
128
+ - **`production` + `disable_vite` true + `FLIGHT_DISABLE_SPA_PIPELINE`**: **Legacy** stack only—no SPA HTML fallback for unknown GETs; tail `koa-static` only after compress/ratelimit/cache.
129
+ - **`development`**: unchanged—Redis session store and production-only static/SPA branches do not apply the SPA pipeline section (the dev Vite server owns UI URLs).
130
+
131
+ ## Configuration: CLI, `.env`, and environment variables
132
+
133
+ Flight loads a **`.env`** file from the **current working directory** at startup (via **`dotenv`**). Use **`FLIGHT_*`** in real deployments.
134
+
135
+ **Precedence**
136
+
137
+ - **`mode`**: **`FLIGHT_MODE`** wins over **`--mode`** when the variable is set (non-empty).
138
+ - **`disable_vite`**: explicit CLI wins; if omitted, **`FLIGHT_DISABLE_VITE`** (`true` / `1` / `yes` → on); otherwise **`false`**.
139
+ - Other settings: argv value if your launcher passes it, else the **`FLIGHT_*`** column, then default.
140
+
141
+ **Registered CLI options:** Flight registers **`--exclude_paths`**, **`--mode`**, **`--port`**, **`--app_home`**, **`--app_key`**, **`--app_secret`**, **`--payload_limit`**, and **`--disable_vite`** (with kebab-case aliases where listed in `--help`). yargs is not in **strict** mode, so additional flags may still appear on argv if you extend the entrypoint.
142
+
143
+ | Setting (CLI / argv) | Environment variable | Default | Use case |
144
+ | ------------------------------------ | ------------------------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
145
+ | `--exclude_paths`, `--exclude-paths` | `FLIGHT_EXCLUDE_PATHS` | _(empty)_ | Skip subtrees when globbing `**/*.backend.ts` (monorepo vendor trees, fixtures). |
146
+ | `--app_home` | `FLIGHT_APP_HOME` | `.` | Repository root; `process.chdir` target before discovery and static paths. |
147
+ | `--app_key` | `FLIGHT_APP_KEY` | `flightApp` | Session cookie name. |
148
+ | `--app_secret` | `FLIGHT_APP_SECRET` | _(see code)_ | Cookie signing secret(s); comma-separated for key rotation. |
149
+ | _(argv)_ | `FLIGHT_SESSION_DURATION_MS` | `86400000` | Session `maxAge` in ms. |
150
+ | `--port` | `FLIGHT_PORT` | `3000` | HTTP listen port (all modes). |
151
+ | `--payload_limit` | `FLIGHT_PAYLOAD_LIMIT` | `1mb` | `koa-bodyparser` JSON limit. |
152
+ | `--disable_vite` | `FLIGHT_DISABLE_VITE` | `false` | **`true` / `1` / `yes`**: skip `vite build` and treat the process as **built-assets** mode (triggers SPA pipeline rules in production). |
153
+ | `--mode` | `FLIGHT_MODE` | `production` | `development` spawns Vite; `production` enables production middleware. |
154
+ | _(n/a)_ | `FLIGHT_DIST_PATH` | `../dist` | Filesystem root for `koa-static` (relative to cwd **after** chdir to `app_home`). |
155
+ | _(n/a)_ | `FLIGHT_DISABLE_SPA_PIPELINE` | _(unset)_ | **`1` / `true` / `yes`**: force **legacy** production stack (no in-router SPA fallback) while keeping `production` + `disable_vite`. Use for **API-only** workers or until duplicate app middleware is removed. |
156
+ | _(n/a)_ | `FLIGHT_STATIC_PREFIXES` | `/assets,/fonts` | Baseline URL prefixes for **rate-limit skipping** on `GET`/`HEAD` (static hashed chunks). |
157
+ | _(n/a)_ | `FLIGHT_RATE_LIMIT_EXCLUDE_PREFIXES` | _(empty)_ | Extra prefixes merged with `FLIGHT_STATIC_PREFIXES` for the same skip list. |
158
+ | _(n/a)_ | `FLIGHT_SPA_INDEX` | `index.html` | SPA shell file inside the dist root for HTML fallback. |
159
+ | _(n/a)_ | `FLIGHT_SPA_DENY_PREFIXES` | _(empty)_ | Extra prefixes that never get SPA fallback (merged with `/api`, `/health`). |
160
+ | _(n/a)_ | `FLIGHT_TRUST_PROXY` | `false` | **`1` / `true` / `yes`**: set **`app.proxy = true`** so `ctx.ip` / rate-limit id use **`X-Forwarded-For`** behind an L7 LB. |
161
+ | _(n/a)_ | `FLIGHT_HTTP_CACHE` | `false` | **`1` / `true` / `yes`**: enable **`koa-cash`** in the **SPA pipeline** branch only (off by default there). Legacy branch always attaches `koa-cash`. |
162
+ | _(n/a)_ | `FLIGHT_REDIS_HOST` | `localhost` | Redis for sessions, rate limit, and cache adapter. |
163
+ | _(n/a)_ | `FLIGHT_REDIS_PORT` | `6379` | Redis port. |
164
+ | _(n/a)_ | `FLIGHT_MAX_WORKERS` | CPU count | Cap cluster worker count on small nodes. |
165
+
166
+ Example `.env` fragment:
167
+
168
+ ```bash
169
+ FLIGHT_MODE=development
170
+ FLIGHT_APP_HOME=.
171
+ FLIGHT_REDIS_HOST=127.0.0.1
172
+ FLIGHT_REDIS_PORT=6379
173
+ FLIGHT_PORT=3000
174
+ FLIGHT_MAX_WORKERS=4
175
+ ```
176
+
177
+ Production behind a proxy:
178
+
179
+ ```bash
180
+ FLIGHT_MODE=production
181
+ FLIGHT_DISABLE_VITE=true
182
+ FLIGHT_TRUST_PROXY=1
183
+ ```
104
184
 
105
185
  ## Project Structure
106
186
 
107
187
  ```
108
188
  my-app/
109
- ├── components/ # Application components
110
- │ ├── Hello/ # Component directory
111
- │ │ ├── Hello.vue # Vue component view
112
- │ │ └── Hello.backend.ts # Backend routes and logic
113
- ├── assets/ # Static assets
114
- ├── dist/ # Production build output
189
+ ├── components/
190
+ │ └── Hello/
191
+ │ ├── Hello.vue # Vue UI (example)
192
+ │ └── Hello.backend.ts # Koa routes for this component
193
+ ├── assets/
194
+ ├── dist/ # Production build output
115
195
  └── package.json
116
196
  ```
117
197
 
118
- Each component follows a simple structure:
119
-
120
- - `Index.vue`: Contains the Vue component template, script, and styles
121
- - `Index.backend.ts`: Contains the backend routes and logic for the component
122
-
123
- Example component files:
198
+ Example Vue + backend snippets:
124
199
 
125
200
  `components/hello/Index.vue`:
126
201
 
@@ -152,107 +227,67 @@ router.get('/hello', async (ctx) => {
152
227
  export default router.routes()
153
228
  ```
154
229
 
155
- ## Configuration
230
+ ## Development mode
156
231
 
157
- Flight can be configured through environment variables or command-line arguments:
232
+ - **Vue** + **Vite** with HMR on port **3001**
233
+ - Backend worker on **`--port`** (default **3000**)
234
+ - Request logging via **`koa-logger`**
158
235
 
159
- ```bash
160
- FLIGHT_MODE=development
161
- FLIGHT_REDIS_HOST=localhost
162
- FLIGHT_REDIS_PORT=6379
163
- FLIGHT_MAX_WORKERS=4
164
- ```
165
-
166
- ## Development Mode
167
-
168
- In development mode, Flight provides:
169
-
170
- - Hot Module Replacement (HMR)
171
- - Fast refresh for React components
172
- - Detailed error messages
173
- - Development server on port 3001
174
-
175
- ## Production Mode
236
+ ## Production mode
176
237
 
177
- Production mode includes:
238
+ - Optional **`npx vite build`** before serving static assets when **`disable_vite`** is **false**
239
+ - Cluster workers capped by **`FLIGHT_MAX_WORKERS`**
240
+ - Compression + Redis-backed rate limiting in all production configurations
241
+ - Static dist + SPA behavior depends on **built-assets mode**—see next section
178
242
 
179
- - Optimized builds
180
- - Rate limiting
181
- - Response compression
182
- - Redis caching
183
- - Cluster mode for load balancing
184
- - Production server on port 3000
243
+ ## Production SPA pipeline (v2)
185
244
 
186
- ## Comparison with Avian
245
+ **When it is on (option B):** `mode === 'production'` **and** **`disable_vite === true`** **and** **`FLIGHT_DISABLE_SPA_PIPELINE` is not truthy.**
187
246
 
188
- Flight is a modern reimagining of the Avian framework, making several architectural improvements while maintaining the core philosophy of component-driven applications. Here are the key differences:
247
+ **What it does:** After your **`router`** (all `**/*.backend.ts` routes), Flight serves **`FLIGHT_DIST_PATH`** with **`koa-static`**, then applies an **`index.html`** fallback for typical browser navigations (see tests in `src/spa-pipeline.test.ts` for the behavioral contract). Only then runs **`koa-compress`**, Redis **`koa-ratelimit`** (with **`GET`/`HEAD` skipped** under `FLIGHT_STATIC_PREFIXES` ∪ `FLIGHT_RATE_LIMIT_EXCLUDE_PREFIXES`), and optionally **`koa-cash`** when **`FLIGHT_HTTP_CACHE`** is enabled.
189
248
 
190
- ### Framework Evolution
249
+ **Path ownership**
191
250
 
192
- - **Koa Instead of Express**: Flight uses Koa.js as its foundation instead of Express, providing better async/await support and a more modern middleware architecture
193
- - **Vite Instead of Webpack**: Replaced Webpack bundling with Vite for significantly faster development experience and simpler configuration
194
- - **TypeScript First**: While Avian supported TypeScript, Flight is built with TypeScript from the ground up
251
+ - **API / BFF:** Implement on your `Router` in `*.backend.ts` under prefixes you own (conventionally **`/api/...`**). Those paths are excluded from the SPA fallback, along with **`/health`** and any **`FLIGHT_SPA_DENY_PREFIXES`**.
252
+ - **Static:** Any file present under the dist root is served by **`koa-static`** first.
253
+ - **SPA:** Remaining **`GET`/`HEAD`** requests that look like document navigations and whose last path segment has **no dot** (so `file.ext` URLs are not rewritten) may receive **`FLIGHT_SPA_INDEX`**.
195
254
 
196
- ### Architectural Improvements
255
+ **Load balancers and `X-Forwarded-For`**
197
256
 
198
- 1. **Simplified Component Structure**
199
- - Avian: Complex component hierarchy with multiple file types (.client, .server, .view, .config)
200
- - Flight: Streamlined with `.backend.ts` files and modern frontend frameworks
201
- 2. **Development Experience**
202
- - Avian: Webpack-based bundling with slower rebuild times
203
- - Flight: Vite-powered development with instant HMR and no bundle step in development
257
+ - Set **`FLIGHT_TRUST_PROXY=1`** when Flight sits behind a trusted reverse proxy so **`ctx.ip`** and the rate limiter’s **`id`** reflect the client. Configure your proxy to append **one** well-formed **`X-Forwarded-For`** chain.
258
+ - Sessions use cookies (`sameSite: true` in code today); sticky sessions or shared Redis are operational choices outside Flight’s defaults.
204
259
 
205
- 3. **Session Management**
206
- - Avian: Express-session with Redis store
207
- - Flight: Koa-session with Redis store, improved security defaults
260
+ ### Middleware order (mermaid)
208
261
 
209
- 4. **Performance Features**
210
- - Built-in rate limiting
211
- - Redis-based caching
212
- - Automatic compression in production
213
- - Cluster mode for CPU utilization
262
+ **SPA pipeline (default when production + `disable_vite`):**
214
263
 
215
- 5. **Configuration**
216
- - Avian: Complex webpack configuration and multiple build modes
217
- - Flight: Simplified configuration with sensible defaults and Vite's zero-config approach
218
-
219
- ### What's Different
220
-
221
- 1. **Removed Features**
222
- - Removed Webpack-specific configurations
223
- - Removed legacy view engine support (EJS, Twig, Pug)
224
- - Removed Sentry integration (can be added as middleware if needed)
225
- - Removed built-in cron job scheduler (better handled by dedicated services)
226
-
227
- 2. **New Features**
228
- - Native ESM support
229
- - Built-in CORS support
230
- - Improved Redis integration
231
- - Better security defaults
232
- - Simpler API for backend routes
233
- - Modern frontend tooling support
264
+ ```mermaid
265
+ flowchart TD
266
+ A[cors + bodyparser + session] --> B[router + *.backend.ts]
267
+ B --> C[koa-static dist]
268
+ C --> D[SPA index.html fallback]
269
+ D --> E[koa-compress]
270
+ E --> F[koa-ratelimit with prefix skips]
271
+ F --> G{koa-cash}
272
+ G -->|FLIGHT_HTTP_CACHE on| H[Redis-backed cache]
273
+ G -->|default| I[no cache middleware]
274
+ ```
234
275
 
235
- 3. **Simplified Architecture**
236
- - Reduced configuration complexity
237
- - More intuitive component organization
238
- - Better separation of concerns
239
- - Modern middleware approach
276
+ **Legacy production stack** (`disable_vite` false, or **`FLIGHT_DISABLE_SPA_PIPELINE`** set): `router` → **`compress`** → **`ratelimit`** → **`koa-cash`** → **`koa-static`**.
240
277
 
241
- ### Migration from Avian
278
+ ### Migrating from Flight 1.x
242
279
 
243
- If you're migrating from Avian, the main changes you'll need to make are:
280
+ 1. If you rely on the **old** order (everything after `router` saw compress/ratelimit/cache **before** static), set **`FLIGHT_DISABLE_SPA_PIPELINE=1`** until you validate the new behavior.
281
+ 2. If your app already shipped **custom SPA / history fallback** middleware in `*.backend.ts`, remove the duplicate once you confirm Flight’s fallback matches your routes (especially non-`/api` prefixes—use **`FLIGHT_SPA_DENY_PREFIXES`**).
282
+ 3. Expect **`koa-cash` off** in the SPA pipeline unless you opt in with **`FLIGHT_HTTP_CACHE`**.
244
283
 
245
- 1. Update your component structure to use `.backend.ts` files
246
- 2. Move from Express middleware to Koa middleware
247
- 3. Update your frontend bundling to use Vite
248
- 4. Adapt to the new session management system
249
- 5. Update your static file serving configuration
284
+ See **[CHANGELOG.md](CHANGELOG.md)** for the full **2.0.0** notes.
250
285
 
251
286
  ## Requirements
252
287
 
253
- - Node.js 16.x or higher
254
- - Redis server
255
- - TypeScript 4.x or higher
288
+ - Node.js **16.x** or higher
289
+ - **Redis** (sessions / rate limit / cache integrations)
290
+ - **TypeScript** in your app if you author `.backend.ts` modules as TS
256
291
 
257
292
  ## License
258
293
 
@@ -260,14 +295,10 @@ MIT
260
295
 
261
296
  ## Contributing
262
297
 
263
- Contributions are welcome! Please feel free to submit a Pull Request.
298
+ Issues and pull requests are welcome. Flight improves fastest with real workloads—if you hit an edge case, open an issue with a minimal repro.
264
299
 
265
300
  ## Acknowledgments
266
301
 
267
- Flight succeeds the [Avian](https://github.com/ispyhumanfly/avian) framework. Avian began at ThoughtPivot and was later handed to FlyPaper Technologies, LLC, where it grew into the platform many teams relied on. Flight is born from that lineage—the ideas and hardening FlyPaper contributed to Avian—carried forward as ThoughtPivot’s next-generation server.
268
-
269
- We’re grateful to FlyPaper Technologies for stewarding Avian and advancing component-driven architecture.
270
-
271
- ## ThoughtPivot
302
+ **Flight** succeeds **[Avian](https://github.com/ispyhumanfly/avian)**—the component-oriented Node server that helped prove this programming model. **Dan Stevenson** created Avian and carried it into **FlyPaper Technologies**, where **Nick Fredericks**, Dan, and the FlyPaper team sharpened Avian’s component boundaries and pushed its operational story. Dan continued to maintain Avian while **Flight** took shape to embrace newer tooling and a cleaner baseline for the next decade.
272
303
 
273
- Today Flight is an official ThoughtPivot technology: developed and maintained by the [ThoughtPivot](https://github.com/thoughtpivot) organization as part of its ecosystem.
304
+ Today **Flight** is maintained by the **ThoughtPivot** engineering team and **contributors like you**—the same spirit of openness and iteration, with a hard focus on speed, clarity, and deployment flexibility.
package/dist/flight.js CHANGED
@@ -1,9 +1,10 @@
1
- #!ts-node
1
+ #!/usr/bin/env node
2
2
  "use strict";
3
3
  var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  return (mod && mod.__esModule) ? mod : { "default": mod };
5
5
  };
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
+ require("dotenv/config");
7
8
  const child_process_1 = require("child_process");
8
9
  const koa_1 = __importDefault(require("koa"));
9
10
  const ioredis_1 = __importDefault(require("ioredis"));
@@ -21,6 +22,7 @@ const path_1 = __importDefault(require("path"));
21
22
  const koa_ratelimit_1 = __importDefault(require("koa-ratelimit"));
22
23
  const koa_static_1 = __importDefault(require("koa-static"));
23
24
  const koa_session_1 = __importDefault(require("koa-session"));
25
+ const spa_pipeline_js_1 = require("./spa-pipeline.js");
24
26
  function normalizeExcludePaths(value) {
25
27
  if (value == null || value === '')
26
28
  return [];
@@ -67,6 +69,39 @@ const argv = yargsEntry(process.argv.slice(2))
67
69
  string: true,
68
70
  default: [],
69
71
  describe: 'Directories under app_home to skip when discovering **/*.backend.ts (repeat flag or comma-separated)'
72
+ })
73
+ .option('mode', {
74
+ type: 'string',
75
+ describe: 'development (Vite HMR) or production'
76
+ })
77
+ .option('port', {
78
+ type: 'number',
79
+ describe: 'HTTP listen port'
80
+ })
81
+ .option('app_home', {
82
+ alias: 'app-home',
83
+ type: 'string',
84
+ describe: 'Application root directory'
85
+ })
86
+ .option('app_key', {
87
+ alias: 'app-key',
88
+ type: 'string',
89
+ describe: 'Session cookie name'
90
+ })
91
+ .option('app_secret', {
92
+ alias: 'app-secret',
93
+ type: 'string',
94
+ describe: 'Session signing secret(s), comma-separated for rotation'
95
+ })
96
+ .option('payload_limit', {
97
+ alias: 'payload-limit',
98
+ type: 'string',
99
+ describe: 'koa-bodyparser JSON body limit'
100
+ })
101
+ .option('disable_vite', {
102
+ alias: 'disable-vite',
103
+ type: 'boolean',
104
+ describe: 'Skip vite build in production; enables built-assets / SPA pipeline when mode is production'
70
105
  })
71
106
  .parseSync();
72
107
  // Set default session duration (24 hours in milliseconds)
@@ -79,13 +114,13 @@ if (isNaN(argv.session_duration) || argv.session_duration < 0) {
79
114
  argv.session_duration = DEFAULT_SESSION_DURATION;
80
115
  }
81
116
  if (!argv.app_home) {
82
- argv.app_home = '.';
117
+ argv.app_home = process.env.FLIGHT_APP_HOME || '.';
83
118
  }
84
119
  if (!argv.app_key) {
85
- argv.app_key = 'flightApp';
120
+ argv.app_key = process.env.FLIGHT_APP_KEY || 'flightApp';
86
121
  }
87
122
  if (!argv.app_secret) {
88
- argv.app_secret = 'the best secret key in the world';
123
+ argv.app_secret = process.env.FLIGHT_APP_SECRET || 'the best secret key in the world';
89
124
  }
90
125
  // Set default port values
91
126
  if (!argv.port) {
@@ -104,8 +139,8 @@ if (isNaN(argv.port) || argv.port < 1 || argv.port > 65535) {
104
139
  }
105
140
  // Set default value for disable_vite flag
106
141
  if (argv.disable_vite === undefined) {
107
- // Check for environment variable first, then default to false
108
- argv.disable_vite = process.env.FLIGHT_DISABLE_VITE === 'true' ? true : false;
142
+ const dv = process.env.FLIGHT_DISABLE_VITE;
143
+ argv.disable_vite = dv === 'true' || dv === '1' || dv === 'yes';
109
144
  }
110
145
  // Ensure the value is a boolean
111
146
  argv.disable_vite = Boolean(argv.disable_vite);
@@ -152,6 +187,7 @@ if (cluster_1.default.isPrimary) {
152
187
  }
153
188
  else {
154
189
  const app = new koa_1.default();
190
+ (0, spa_pipeline_js_1.applyTrustProxy)(app);
155
191
  app.use((0, koa_logger_1.default)());
156
192
  app.keys = argv.app_secret.split(',');
157
193
  const SESSION_CONFIG = {
@@ -191,8 +227,15 @@ else {
191
227
  console.error(`stderr: ${stderr}`);
192
228
  });
193
229
  }
194
- app.use((0, koa_compress_1.default)());
195
- app.use((0, koa_ratelimit_1.default)({
230
+ const distRoot = (0, spa_pipeline_js_1.resolveDistRoot)(process.cwd());
231
+ const useSpaPipeline = (0, spa_pipeline_js_1.productionSpaPipelineActive)(mode, Boolean(argv.disable_vite));
232
+ const staticPrefixes = (0, spa_pipeline_js_1.parseCommaPrefixes)(process.env.FLIGHT_STATIC_PREFIXES, '/assets,/fonts');
233
+ const rateLimitSkipPrefixes = dedupeStrings([
234
+ ...staticPrefixes,
235
+ ...(0, spa_pipeline_js_1.parseCommaPrefixes)(process.env.FLIGHT_RATE_LIMIT_EXCLUDE_PREFIXES, '')
236
+ ]);
237
+ const spaDenyExtra = (0, spa_pipeline_js_1.parseCommaPrefixes)(process.env.FLIGHT_SPA_DENY_PREFIXES, '');
238
+ const productionRatelimit = (0, koa_ratelimit_1.default)({
196
239
  driver: 'redis',
197
240
  db: redis,
198
241
  duration: 60000,
@@ -205,12 +248,27 @@ else {
205
248
  },
206
249
  max: 1200,
207
250
  disableHeader: false
208
- }));
209
- app.use((0, koa_cash_1.default)({
251
+ });
252
+ const productionKoaCash = (0, koa_cash_1.default)({
210
253
  get: (key) => redis.get(key),
211
254
  set: (key, value) => redis.set(key, value, 'EX', 30)
212
- }));
213
- app.use((0, koa_static_1.default)(process.env.FLIGHT_DIST_PATH || '../dist'));
255
+ });
256
+ if (useSpaPipeline) {
257
+ console.log('Flight: production SPA pipeline (static + index.html fallback before compress / rate limit); opt out with FLIGHT_DISABLE_SPA_PIPELINE=1');
258
+ app.use((0, koa_static_1.default)(distRoot));
259
+ app.use((0, spa_pipeline_js_1.spaIndexHtmlFallback)(distRoot, (0, spa_pipeline_js_1.spaIndexRelative)(), spaDenyExtra));
260
+ app.use((0, koa_compress_1.default)());
261
+ app.use((0, spa_pipeline_js_1.ratelimitWithPrefixSkips)(redis, rateLimitSkipPrefixes));
262
+ if ((0, spa_pipeline_js_1.httpCacheEnabledInSpaPipeline)()) {
263
+ app.use(productionKoaCash);
264
+ }
265
+ }
266
+ else {
267
+ app.use((0, koa_compress_1.default)());
268
+ app.use(productionRatelimit);
269
+ app.use(productionKoaCash);
270
+ app.use((0, koa_static_1.default)(distRoot));
271
+ }
214
272
  if (!argv.disable_vite) {
215
273
  console.log(`App served out of dist/ and available on port ${argv.port}`);
216
274
  }
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.productionSpaPipelineActive = productionSpaPipelineActive;
7
+ exports.applyTrustProxy = applyTrustProxy;
8
+ exports.parseCommaPrefixes = parseCommaPrefixes;
9
+ exports.resolveDistRoot = resolveDistRoot;
10
+ exports.spaIndexRelative = spaIndexRelative;
11
+ exports.spaIndexHtmlFallback = spaIndexHtmlFallback;
12
+ exports.shouldSkipRateLimitForPath = shouldSkipRateLimitForPath;
13
+ exports.ratelimitWithPrefixSkips = ratelimitWithPrefixSkips;
14
+ exports.httpCacheEnabledInSpaPipeline = httpCacheEnabledInSpaPipeline;
15
+ const fs_1 = __importDefault(require("fs"));
16
+ const path_1 = __importDefault(require("path"));
17
+ const koa_ratelimit_1 = __importDefault(require("koa-ratelimit"));
18
+ /** Option B: production + built assets (disable_vite), unless explicitly opted out. */
19
+ function productionSpaPipelineActive(mode, disableVite, env = process.env) {
20
+ if (mode !== 'production')
21
+ return false;
22
+ if (!disableVite)
23
+ return false;
24
+ const d = env.FLIGHT_DISABLE_SPA_PIPELINE;
25
+ if (d === '1' || d === 'true' || d === 'yes')
26
+ return false;
27
+ return true;
28
+ }
29
+ function applyTrustProxy(app, env = process.env) {
30
+ const t = env.FLIGHT_TRUST_PROXY;
31
+ app.proxy = t === '1' || t === 'true' || t === 'yes';
32
+ }
33
+ function parseCommaPrefixes(value, fallback) {
34
+ const raw = (value ?? fallback).trim();
35
+ if (!raw)
36
+ return [];
37
+ return raw
38
+ .split(',')
39
+ .map((s) => s.trim())
40
+ .filter(Boolean)
41
+ .map((p) => (p.startsWith('/') ? p : `/${p}`));
42
+ }
43
+ function resolveDistRoot(cwd, env = process.env) {
44
+ return path_1.default.resolve(cwd, env.FLIGHT_DIST_PATH || '../dist');
45
+ }
46
+ function spaIndexRelative(env = process.env) {
47
+ const v = (env.FLIGHT_SPA_INDEX || 'index.html').trim();
48
+ return v.replace(/^\/+/, '');
49
+ }
50
+ const DEFAULT_DENY_PREFIXES = ['/api', '/health'];
51
+ /** Match connect-history-api-fallback default: do not rewrite paths whose last segment looks like a file name. */
52
+ function lastPathSegmentLooksLikeFile(urlPath) {
53
+ const base = urlPath.slice(urlPath.lastIndexOf('/') + 1);
54
+ return base.includes('.');
55
+ }
56
+ /**
57
+ * After koa-static: serve index.html for document navigations that are not API, health, or static-like paths.
58
+ */
59
+ function spaIndexHtmlFallback(distRoot, indexRel, extraDenyPrefixes = []) {
60
+ const indexAbs = path_1.default.join(distRoot, indexRel);
61
+ const deny = dedupePathList([...DEFAULT_DENY_PREFIXES, ...extraDenyPrefixes]);
62
+ return async (ctx, next) => {
63
+ if (ctx.method !== 'GET' && ctx.method !== 'HEAD')
64
+ return next();
65
+ if (ctx.body != null)
66
+ return next();
67
+ const accept = ctx.get('accept') || '';
68
+ if (accept && !accept.includes('text/html') && !accept.includes('*/*'))
69
+ return next();
70
+ const urlPath = ctx.path.split('?')[0];
71
+ if (lastPathSegmentLooksLikeFile(urlPath))
72
+ return next();
73
+ for (const pre of deny) {
74
+ if (urlPath === pre || (pre !== '/' && urlPath.startsWith(`${pre}/`))) {
75
+ return next();
76
+ }
77
+ }
78
+ try {
79
+ await fs_1.default.promises.access(indexAbs, fs_1.default.constants.R_OK);
80
+ }
81
+ catch {
82
+ return next();
83
+ }
84
+ ctx.type = 'text/html; charset=utf-8';
85
+ ctx.body = fs_1.default.createReadStream(indexAbs);
86
+ };
87
+ }
88
+ function dedupePathList(paths) {
89
+ return [...new Set(paths)];
90
+ }
91
+ function shouldSkipRateLimitForPath(path, method, prefixes) {
92
+ if (method !== 'GET' && method !== 'HEAD')
93
+ return false;
94
+ return prefixes.some((pre) => path === pre || path.startsWith(`${pre}/`));
95
+ }
96
+ /** Same ratelimit as Flight legacy, but skips GET/HEAD under configured prefixes (e.g. hashed assets). */
97
+ function ratelimitWithPrefixSkips(redis, skipPrefixes) {
98
+ const inner = (0, koa_ratelimit_1.default)({
99
+ driver: 'redis',
100
+ db: redis,
101
+ duration: 60000,
102
+ errorMessage: 'Sometimes You Just Have to Slow Down.',
103
+ id: (ctx) => ctx.get('x-forwarded-for') || ctx.ip,
104
+ headers: {
105
+ remaining: 'Rate-Limit-Remaining',
106
+ reset: 'Rate-Limit-Reset',
107
+ total: 'Rate-Limit-Total'
108
+ },
109
+ max: 1200,
110
+ disableHeader: false
111
+ });
112
+ return async (ctx, next) => {
113
+ if (shouldSkipRateLimitForPath(ctx.path, ctx.method, skipPrefixes)) {
114
+ return next();
115
+ }
116
+ await inner(ctx, next);
117
+ };
118
+ }
119
+ function httpCacheEnabledInSpaPipeline(env = process.env) {
120
+ const v = env.FLIGHT_HTTP_CACHE;
121
+ return v === '1' || v === 'true' || v === 'yes';
122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thoughtpivot/flight",
3
- "version": "1.1.0",
3
+ "version": "2.0.2",
4
4
  "description": "A web server designed for running full stack, component based applications at enterprise scale.",
5
5
  "main": "src/flight.ts",
6
6
  "publishConfig": {
@@ -12,7 +12,8 @@
12
12
  "scripts": {
13
13
  "lint": "eslint . --fix && prettier -w .",
14
14
  "build": "rm -rf dist; node_modules/.bin/tsc --project .",
15
- "test": "echo \"Error: no test specified\" && exit 0"
15
+ "test": "npm run build && node --test dist/spa-pipeline.test.js",
16
+ "prepublishOnly": "npm run build"
16
17
  },
17
18
  "repository": {
18
19
  "type": "git",
@@ -24,6 +25,13 @@
24
25
  "url": "https://github.com/thoughtpivot/flight/issues"
25
26
  },
26
27
  "homepage": "https://github.com/thoughtpivot/flight#readme",
28
+ "files": [
29
+ "dist/flight.js",
30
+ "dist/spa-pipeline.js",
31
+ "README.md",
32
+ "CHANGELOG.md",
33
+ "readme-assets"
34
+ ],
27
35
  "dependencies": {
28
36
  "@koa/cors": "^5.0.0",
29
37
  "@koa/router": "^13.1.0",
@@ -64,11 +72,13 @@
64
72
  "@types/koa-session": "^6.4.5",
65
73
  "@types/koa-static": "^4.0.4",
66
74
  "@types/node": "^22.19.17",
75
+ "@types/supertest": "^6.0.2",
67
76
  "@typescript-eslint/parser": "^8.59.1",
68
77
  "eslint": "^9.39.4",
69
78
  "eslint-plugin-jsdoc": "^50.8.0",
70
79
  "eslint-plugin-vue": "^9.33.0",
71
80
  "prettier": "^3.8.3",
81
+ "supertest": "^7.0.0",
72
82
  "ts-node": "^10.9.2",
73
83
  "typescript": "^5.9.3"
74
84
  }
@@ -0,0 +1,17 @@
1
+ <svg width="5343" height="865" viewBox="0 0 5343 865" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M642.666 865C533.606 865 431.066 865 322.006 865C393.594 596.025 462.176 327.554 533.606 58.4919C543.977 18.4241 561.039 0.0278033 599.015 0.0278033C743.037 0.0278033 887.058 0.0278033 1031.25 0.0278033C993.277 0.0278033 976.214 18.4241 965.844 58.4919C894.413 327.554 854.267 487.462 783.01 756.611C765.781 821.507 708.067 865 642.666 865Z" fill="black"/>
3
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M0 187.175C9.20051 152.387 24.7565 93.4178 33.957 58.4641C44.3263 18.3964 61.3904 3.89425e-05 99.3628 3.89425e-05C243.386 3.89425e-05 387.581 3.89425e-05 531.602 3.89425e-05C493.791 3.89425e-05 476.737 18.3964 466.193 58.4641C456.494 95.2567 440.939 153.973 432.234 187.175H0Z" fill="black"/>
4
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M1426.35 206.948C1459.81 76.2171 1380.18 0.0276436 1243.85 0.0276436H1098.49C1060.69 0.0276436 1043.63 18.424 1033.08 58.4917C961.661 327.554 970.193 302.623 898.77 571.599C1007.83 571.599 1110.2 571.599 1219.26 571.599C1284.83 571.599 1342.37 528.114 1359.61 463.123C1410.8 270.337 1406.27 284.227 1425.85 208.7V208.613C1426.02 208.029 1426.19 207.445 1426.35 206.948Z" fill="black"/>
5
+ <path d="M1890.76 188.564C1893.08 188.564 1894.93 189.492 1896.32 191.346C1898.18 192.737 1899.1 194.592 1899.1 196.911V264.377C1899.1 266.696 1898.18 268.782 1896.32 270.637C1894.93 272.028 1893.08 272.723 1890.76 272.723H1803.81C1801.49 272.723 1800.33 273.883 1800.33 276.201V667.089C1800.33 669.408 1799.41 671.494 1797.55 673.349C1796.16 674.74 1794.31 675.436 1791.99 675.436H1710.61C1708.29 675.436 1706.2 674.74 1704.35 673.349C1702.96 671.494 1702.26 669.408 1702.26 667.089V276.201C1702.26 273.883 1701.1 272.723 1698.78 272.723H1615.31C1613 272.723 1610.91 272.028 1609.05 270.637C1607.66 268.782 1606.97 266.696 1606.97 264.377V196.911C1606.97 194.592 1607.66 192.737 1609.05 191.346C1610.91 189.492 1613 188.564 1615.31 188.564H1890.76Z" fill="black"/>
6
+ <path d="M2120.83 196.911C2120.83 194.592 2121.53 192.737 2122.92 191.346C2124.77 189.492 2126.86 188.564 2129.18 188.564H2210.56C2212.88 188.564 2214.73 189.492 2216.12 191.346C2217.98 192.737 2218.91 194.592 2218.91 196.911V667.089C2218.91 669.408 2217.98 671.494 2216.12 673.349C2214.73 674.74 2212.88 675.436 2210.56 675.436H2129.18C2126.86 675.436 2124.77 674.74 2122.92 673.349C2121.53 671.494 2120.83 669.408 2120.83 667.089V477.209C2120.83 474.891 2119.67 473.732 2117.35 473.732H2047.8C2045.48 473.732 2044.32 474.891 2044.32 477.209V667.089C2044.32 669.408 2043.39 671.494 2041.54 673.349C2040.15 674.74 2038.29 675.436 2035.97 675.436H1954.59C1952.27 675.436 1950.19 674.74 1948.33 673.349C1946.94 671.494 1946.25 669.408 1946.25 667.089V196.911C1946.25 194.592 1946.94 192.737 1948.33 191.346C1950.19 189.492 1952.27 188.564 1954.59 188.564H2035.97C2038.29 188.564 2040.15 189.492 2041.54 191.346C2043.39 192.737 2044.32 194.592 2044.32 196.911V386.095C2044.32 388.413 2045.48 389.573 2047.8 389.573H2117.35C2119.67 389.573 2120.83 388.413 2120.83 386.095V196.911Z" fill="black"/>
7
+ <path d="M2413.99 681C2371.33 681 2337.25 668.48 2311.74 643.441C2286.24 618.402 2273.49 585.017 2273.49 543.285V320.715C2273.49 278.983 2286.24 245.598 2311.74 220.559C2337.25 195.52 2371.33 183 2413.99 183C2456.65 183 2490.74 195.52 2516.24 220.559C2542.21 245.598 2555.19 278.983 2555.19 320.715V543.285C2555.19 585.017 2542.21 618.402 2516.24 643.441C2490.74 668.48 2456.65 681 2413.99 681ZM2413.99 596.841C2426.98 596.841 2437.41 592.436 2445.29 583.626C2453.17 574.352 2457.12 562.296 2457.12 547.458V316.542C2457.12 301.704 2453.17 289.88 2445.29 281.07C2437.41 271.796 2426.98 267.159 2413.99 267.159C2401.01 267.159 2390.57 271.796 2382.69 281.07C2375.27 289.88 2371.56 301.704 2371.56 316.542V547.458C2371.56 562.296 2375.27 574.352 2382.69 583.626C2390.57 592.436 2401.01 596.841 2413.99 596.841Z" fill="black"/>
8
+ <path d="M2745.54 681C2703.8 681 2670.18 668.017 2644.68 642.05C2619.64 615.62 2607.12 580.612 2607.12 537.025V196.911C2607.12 194.592 2607.81 192.737 2609.21 191.346C2611.06 189.492 2613.15 188.564 2615.47 188.564H2696.85C2699.17 188.564 2701.02 189.492 2702.41 191.346C2704.27 192.737 2705.19 194.592 2705.19 196.911V546.763C2705.19 561.601 2708.9 573.656 2716.32 582.93C2723.74 592.204 2733.48 596.841 2745.54 596.841C2757.59 596.841 2767.1 592.204 2774.06 582.93C2781.47 573.656 2785.18 561.601 2785.18 546.763V196.911C2785.18 194.592 2785.88 192.737 2787.27 191.346C2789.13 189.492 2791.21 188.564 2793.53 188.564H2874.91C2877.23 188.564 2879.09 189.492 2880.48 191.346C2882.33 192.737 2883.26 194.592 2883.26 196.911V537.025C2883.26 580.612 2870.51 615.62 2845 642.05C2819.96 668.017 2786.81 681 2745.54 681Z" fill="black"/>
9
+ <path d="M3071.47 681C3029.74 681 2996.12 669.176 2970.61 645.528C2945.57 621.416 2933.05 589.422 2933.05 549.545V314.455C2933.05 274.578 2945.57 242.816 2970.61 219.168C2996.12 195.056 3029.74 183 3071.47 183C3113.2 183 3146.82 195.056 3172.33 219.168C3197.83 243.279 3210.58 275.274 3210.58 315.151V341.581C3210.58 343.899 3209.65 345.986 3207.8 347.841C3206.41 349.232 3204.55 349.927 3202.24 349.927H3120.85C3118.54 349.927 3116.45 349.232 3114.59 347.841C3113.2 345.986 3112.51 343.899 3112.51 341.581V313.76C3112.51 299.849 3108.8 288.721 3101.38 280.374C3093.96 271.564 3083.99 267.159 3071.47 267.159C3059.41 267.159 3049.67 271.564 3042.26 280.374C3034.84 288.721 3031.13 299.849 3031.13 313.76V550.24C3031.13 564.151 3034.84 575.511 3042.26 584.321C3049.67 592.668 3059.41 596.841 3071.47 596.841C3083.99 596.841 3093.96 592.668 3101.38 584.321C3108.8 575.511 3112.51 564.151 3112.51 550.24V494.598C3112.51 492.279 3111.35 491.12 3109.03 491.12H3078.42C3076.11 491.12 3074.02 490.425 3072.16 489.034C3070.77 487.179 3070.08 485.092 3070.08 482.774V420.871C3070.08 418.553 3070.77 416.698 3072.16 415.307C3074.02 413.452 3076.11 412.525 3078.42 412.525H3202.24C3204.55 412.525 3206.41 413.452 3207.8 415.307C3209.65 416.698 3210.58 418.553 3210.58 420.871V549.545C3210.58 589.422 3197.83 621.416 3172.33 645.528C3146.82 669.176 3113.2 681 3071.47 681Z" fill="black"/>
10
+ <path d="M3439.28 196.911C3439.28 194.592 3439.98 192.737 3441.37 191.346C3443.22 189.492 3445.31 188.564 3447.63 188.564H3529.01C3531.33 188.564 3533.18 189.492 3534.57 191.346C3536.43 192.737 3537.36 194.592 3537.36 196.911V667.089C3537.36 669.408 3536.43 671.494 3534.57 673.349C3533.18 674.74 3531.33 675.436 3529.01 675.436H3447.63C3445.31 675.436 3443.22 674.74 3441.37 673.349C3439.98 671.494 3439.28 669.408 3439.28 667.089V477.209C3439.28 474.891 3438.12 473.732 3435.8 473.732H3366.25C3363.93 473.732 3362.77 474.891 3362.77 477.209V667.089C3362.77 669.408 3361.84 671.494 3359.99 673.349C3358.6 674.74 3356.74 675.436 3354.42 675.436H3273.04C3270.72 675.436 3268.64 674.74 3266.78 673.349C3265.39 671.494 3264.69 669.408 3264.69 667.089V196.911C3264.69 194.592 3265.39 192.737 3266.78 191.346C3268.64 189.492 3270.72 188.564 3273.04 188.564H3354.42C3356.74 188.564 3358.6 189.492 3359.99 191.346C3361.84 192.737 3362.77 194.592 3362.77 196.911V386.095C3362.77 388.413 3363.93 389.573 3366.25 389.573H3435.8C3438.12 389.573 3439.28 388.413 3439.28 386.095V196.911Z" fill="black"/>
11
+ <path d="M3868.77 188.564C3871.09 188.564 3872.94 189.492 3874.34 191.346C3876.19 192.737 3877.12 194.592 3877.12 196.911V264.377C3877.12 266.696 3876.19 268.782 3874.34 270.637C3872.94 272.028 3871.09 272.723 3868.77 272.723H3781.83C3779.51 272.723 3778.35 273.883 3778.35 276.201V667.089C3778.35 669.408 3777.42 671.494 3775.57 673.349C3774.17 674.74 3772.32 675.436 3770 675.436H3688.62C3686.3 675.436 3684.21 674.74 3682.36 673.349C3680.97 671.494 3680.27 669.408 3680.27 667.089V276.201C3680.27 273.883 3679.11 272.723 3676.8 272.723H3593.33C3591.01 272.723 3588.92 272.028 3587.07 270.637C3585.68 268.782 3584.98 266.696 3584.98 264.377V196.911C3584.98 194.592 3585.68 192.737 3587.07 191.346C3588.92 189.492 3591.01 188.564 3593.33 188.564H3868.77Z" fill="black"/>
12
+ <path d="M4072.41 187.869C4097.92 187.869 4120.64 194.128 4140.58 206.648C4160.52 219.168 4175.82 236.788 4186.49 259.508C4197.62 281.765 4203.18 307.268 4203.18 336.017C4203.18 379.14 4191.59 413.684 4168.4 439.651C4145.22 465.617 4114.84 478.601 4077.28 478.601H4025.12C4022.8 478.601 4021.64 479.76 4021.64 482.078V667.089C4021.64 669.408 4020.71 671.494 4018.86 673.349C4017.46 674.74 4015.61 675.436 4013.29 675.436H3931.91C3929.59 675.436 3927.5 674.74 3925.65 673.349C3924.26 671.494 3923.56 669.408 3923.56 667.089V196.215C3923.56 193.897 3924.26 192.042 3925.65 190.651C3927.5 188.796 3929.59 187.869 3931.91 187.869H4072.41ZM4055.72 402.092C4070.56 402.092 4082.38 396.528 4091.19 385.399C4100.47 373.807 4105.11 358.042 4105.11 338.103C4105.11 317.701 4100.47 301.704 4091.19 290.112C4082.38 278.52 4070.56 272.723 4055.72 272.723H4025.12C4022.8 272.723 4021.64 273.883 4021.64 276.201V398.614C4021.64 400.933 4022.8 402.092 4025.12 402.092H4055.72Z" fill="#4B7CB5"/>
13
+ <path d="M4255.93 675.436C4253.62 675.436 4251.53 674.74 4249.67 673.349C4248.28 671.494 4247.59 669.408 4247.59 667.089V196.911C4247.59 194.592 4248.28 192.737 4249.67 191.346C4251.53 189.492 4253.62 188.564 4255.93 188.564H4337.32C4339.63 188.564 4341.49 189.492 4342.88 191.346C4344.74 192.737 4345.66 194.592 4345.66 196.911V667.089C4345.66 669.408 4344.74 671.494 4342.88 673.349C4341.49 674.74 4339.63 675.436 4337.32 675.436H4255.93Z" fill="#4B7CB5"/>
14
+ <path d="M4501.8 675.436C4496.69 675.436 4493.68 672.885 4492.75 667.785L4393.29 197.606L4392.59 194.824C4392.59 190.651 4395.14 188.564 4400.24 188.564H4489.97C4495.54 188.564 4498.55 191.115 4499.01 196.215L4547.01 502.249C4547.47 504.103 4548.17 505.031 4549.09 505.031C4550.02 505.031 4550.72 504.103 4551.18 502.249L4597.78 196.215C4598.25 191.115 4601.26 188.564 4606.83 188.564H4693.77C4699.8 188.564 4702.12 191.578 4700.73 197.606L4600.57 667.785C4599.64 672.885 4596.62 675.436 4591.52 675.436H4501.8Z" fill="#4B7CB5"/>
15
+ <path d="M4869.53 681C4826.87 681 4792.78 668.48 4767.28 643.441C4741.77 618.402 4729.02 585.017 4729.02 543.285V320.715C4729.02 278.983 4741.77 245.598 4767.28 220.559C4792.78 195.52 4826.87 183 4869.53 183C4912.19 183 4946.27 195.52 4971.77 220.559C4997.74 245.598 5010.73 278.983 5010.73 320.715V543.285C5010.73 585.017 4997.74 618.402 4971.77 643.441C4946.27 668.48 4912.19 681 4869.53 681ZM4869.53 596.841C4882.51 596.841 4892.94 592.436 4900.83 583.626C4908.71 574.352 4912.65 562.296 4912.65 547.458V316.542C4912.65 301.704 4908.71 289.88 4900.83 281.07C4892.94 271.796 4882.51 267.159 4869.53 267.159C4856.54 267.159 4846.11 271.796 4838.23 281.07C4830.81 289.88 4827.1 301.704 4827.1 316.542V547.458C4827.1 562.296 4830.81 574.352 4838.23 583.626C4846.11 592.436 4856.54 596.841 4869.53 596.841Z" fill="#4B7CB5"/>
16
+ <path d="M5334.62 188.564C5336.94 188.564 5338.79 189.492 5340.19 191.346C5342.04 192.737 5342.97 194.592 5342.97 196.911V264.377C5342.97 266.696 5342.04 268.782 5340.19 270.637C5338.79 272.028 5336.94 272.723 5334.62 272.723H5247.68C5245.36 272.723 5244.2 273.883 5244.2 276.201V667.089C5244.2 669.408 5243.27 671.494 5241.41 673.349C5240.02 674.74 5238.17 675.436 5235.85 675.436H5154.47C5152.15 675.436 5150.06 674.74 5148.21 673.349C5146.82 671.494 5146.12 669.408 5146.12 667.089V276.201C5146.12 273.883 5144.96 272.723 5142.64 272.723H5059.18C5056.86 272.723 5054.77 272.028 5052.92 270.637C5051.53 268.782 5050.83 266.696 5050.83 264.377V196.911C5050.83 194.592 5051.53 192.737 5052.92 191.346C5054.77 189.492 5056.86 188.564 5059.18 188.564H5334.62Z" fill="#4B7CB5"/>
17
+ </svg>
package/src/flight.ts CHANGED
@@ -1,4 +1,6 @@
1
- #!ts-node
1
+ #!/usr/bin/env node
2
+
3
+ import 'dotenv/config'
2
4
 
3
5
  import { exec, spawn } from 'child_process'
4
6
 
@@ -19,6 +21,17 @@ import ratelimit from 'koa-ratelimit'
19
21
  import serve from 'koa-static'
20
22
  import session from 'koa-session'
21
23
 
24
+ import {
25
+ applyTrustProxy,
26
+ httpCacheEnabledInSpaPipeline,
27
+ parseCommaPrefixes,
28
+ productionSpaPipelineActive,
29
+ ratelimitWithPrefixSkips,
30
+ resolveDistRoot,
31
+ spaIndexHtmlFallback,
32
+ spaIndexRelative
33
+ } from './spa-pipeline.js'
34
+
22
35
  /** CLI argv shape after Flight applies defaults (see block below). */
23
36
  interface FlightArgv {
24
37
  session_duration?: number
@@ -87,6 +100,39 @@ const argv = yargsEntry(process.argv.slice(2))
87
100
  default: [],
88
101
  describe: 'Directories under app_home to skip when discovering **/*.backend.ts (repeat flag or comma-separated)'
89
102
  })
103
+ .option('mode', {
104
+ type: 'string',
105
+ describe: 'development (Vite HMR) or production'
106
+ })
107
+ .option('port', {
108
+ type: 'number',
109
+ describe: 'HTTP listen port'
110
+ })
111
+ .option('app_home', {
112
+ alias: 'app-home',
113
+ type: 'string',
114
+ describe: 'Application root directory'
115
+ })
116
+ .option('app_key', {
117
+ alias: 'app-key',
118
+ type: 'string',
119
+ describe: 'Session cookie name'
120
+ })
121
+ .option('app_secret', {
122
+ alias: 'app-secret',
123
+ type: 'string',
124
+ describe: 'Session signing secret(s), comma-separated for rotation'
125
+ })
126
+ .option('payload_limit', {
127
+ alias: 'payload-limit',
128
+ type: 'string',
129
+ describe: 'koa-bodyparser JSON body limit'
130
+ })
131
+ .option('disable_vite', {
132
+ alias: 'disable-vite',
133
+ type: 'boolean',
134
+ describe: 'Skip vite build in production; enables built-assets / SPA pipeline when mode is production'
135
+ })
90
136
  .parseSync() as FlightArgv
91
137
 
92
138
  // Set default session duration (24 hours in milliseconds)
@@ -102,15 +148,15 @@ if (isNaN(argv.session_duration) || argv.session_duration < 0) {
102
148
  }
103
149
 
104
150
  if (!argv.app_home) {
105
- argv.app_home = '.'
151
+ argv.app_home = process.env.FLIGHT_APP_HOME || '.'
106
152
  }
107
153
 
108
154
  if (!argv.app_key) {
109
- argv.app_key = 'flightApp'
155
+ argv.app_key = process.env.FLIGHT_APP_KEY || 'flightApp'
110
156
  }
111
157
 
112
158
  if (!argv.app_secret) {
113
- argv.app_secret = 'the best secret key in the world'
159
+ argv.app_secret = process.env.FLIGHT_APP_SECRET || 'the best secret key in the world'
114
160
  }
115
161
 
116
162
  // Set default port values
@@ -134,8 +180,8 @@ if (isNaN(argv.port) || argv.port < 1 || argv.port > 65535) {
134
180
 
135
181
  // Set default value for disable_vite flag
136
182
  if (argv.disable_vite === undefined) {
137
- // Check for environment variable first, then default to false
138
- argv.disable_vite = process.env.FLIGHT_DISABLE_VITE === 'true' ? true : false
183
+ const dv = process.env.FLIGHT_DISABLE_VITE
184
+ argv.disable_vite = dv === 'true' || dv === '1' || dv === 'yes'
139
185
  }
140
186
 
141
187
  // Ensure the value is a boolean
@@ -192,6 +238,8 @@ if (cluster.isPrimary) {
192
238
  })
193
239
  } else {
194
240
  const app = new Koa()
241
+ applyTrustProxy(app)
242
+
195
243
  app.use(logger())
196
244
 
197
245
  app.keys = argv.app_secret.split(',')
@@ -245,30 +293,52 @@ if (cluster.isPrimary) {
245
293
  })
246
294
  }
247
295
 
248
- app.use(compress())
249
- app.use(
250
- ratelimit({
251
- driver: 'redis',
252
- db: redis,
253
- duration: 60000,
254
- errorMessage: 'Sometimes You Just Have to Slow Down.',
255
- id: (ctx) => ctx.get('x-forwarded-for') || ctx.ip,
256
- headers: {
257
- remaining: 'Rate-Limit-Remaining',
258
- reset: 'Rate-Limit-Reset',
259
- total: 'Rate-Limit-Total'
260
- },
261
- max: 1200,
262
- disableHeader: false
263
- })
264
- )
265
- app.use(
266
- koaCash({
267
- get: (key) => redis.get(key),
268
- set: (key, value) => redis.set(key, value, 'EX', 30)
269
- })
270
- )
271
- app.use(serve(process.env.FLIGHT_DIST_PATH || '../dist'))
296
+ const distRoot = resolveDistRoot(process.cwd())
297
+ const useSpaPipeline = productionSpaPipelineActive(mode, Boolean(argv.disable_vite))
298
+ const staticPrefixes = parseCommaPrefixes(process.env.FLIGHT_STATIC_PREFIXES, '/assets,/fonts')
299
+ const rateLimitSkipPrefixes = dedupeStrings([
300
+ ...staticPrefixes,
301
+ ...parseCommaPrefixes(process.env.FLIGHT_RATE_LIMIT_EXCLUDE_PREFIXES, '')
302
+ ])
303
+ const spaDenyExtra = parseCommaPrefixes(process.env.FLIGHT_SPA_DENY_PREFIXES, '')
304
+
305
+ const productionRatelimit = ratelimit({
306
+ driver: 'redis',
307
+ db: redis,
308
+ duration: 60000,
309
+ errorMessage: 'Sometimes You Just Have to Slow Down.',
310
+ id: (ctx) => ctx.get('x-forwarded-for') || ctx.ip,
311
+ headers: {
312
+ remaining: 'Rate-Limit-Remaining',
313
+ reset: 'Rate-Limit-Reset',
314
+ total: 'Rate-Limit-Total'
315
+ },
316
+ max: 1200,
317
+ disableHeader: false
318
+ })
319
+
320
+ const productionKoaCash = koaCash({
321
+ get: (key) => redis.get(key),
322
+ set: (key, value) => redis.set(key, value, 'EX', 30)
323
+ })
324
+
325
+ if (useSpaPipeline) {
326
+ console.log(
327
+ 'Flight: production SPA pipeline (static + index.html fallback before compress / rate limit); opt out with FLIGHT_DISABLE_SPA_PIPELINE=1'
328
+ )
329
+ app.use(serve(distRoot))
330
+ app.use(spaIndexHtmlFallback(distRoot, spaIndexRelative(), spaDenyExtra))
331
+ app.use(compress())
332
+ app.use(ratelimitWithPrefixSkips(redis, rateLimitSkipPrefixes))
333
+ if (httpCacheEnabledInSpaPipeline()) {
334
+ app.use(productionKoaCash)
335
+ }
336
+ } else {
337
+ app.use(compress())
338
+ app.use(productionRatelimit)
339
+ app.use(productionKoaCash)
340
+ app.use(serve(distRoot))
341
+ }
272
342
 
273
343
  if (!argv.disable_vite) {
274
344
  console.log(`App served out of dist/ and available on port ${argv.port}`)
@@ -1,83 +0,0 @@
1
- filter-run-always: &filter-run-always
2
- filters:
3
- tags:
4
- only: /.*/
5
-
6
- filter-run-on-master-and-version-tag-only: &filter-run-on-master-and-version-tag-only
7
- filters:
8
- tags:
9
- only: /^v.*/
10
- branches:
11
- only: main
12
-
13
- aliases:
14
- - &step-checkout checkout
15
- - &step-restore-cache
16
- restore_cache:
17
- keys:
18
- - v1-dependencies-{{ checksum "package.json" }}
19
- - v1-dependencies-
20
- - &step-install
21
- run: npm install
22
- - &step-save-cache
23
- save_cache:
24
- paths:
25
- - node_modules
26
- key: v1-dependencies-{{ checksum "package.json" }}
27
- - &step-build
28
- run: npm run build
29
-
30
- build-node-common: &common-build
31
- working_directory: ~/repo
32
- steps:
33
- - *step-checkout
34
- - *step-restore-cache
35
- - *step-install
36
- - *step-save-cache
37
- - *step-build
38
- - run:
39
- name: Testing
40
- command: npm run test
41
-
42
- version: 2
43
- jobs:
44
- deploy:
45
- working_directory: ~/repo
46
- docker:
47
- - image: cimg/node:20.12
48
- steps:
49
- - *step-checkout
50
- - *step-restore-cache
51
- - *step-install
52
- - *step-save-cache
53
- - *step-build
54
- - run:
55
- name: Authenticate with registry
56
- command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc
57
- - run:
58
- name: Publish Package
59
- command: npm publish
60
-
61
- build-node18:
62
- <<: *common-build
63
- docker:
64
- - image: cimg/node:18.20
65
-
66
- build-node20:
67
- <<: *common-build
68
- docker:
69
- - image: cimg/node:20.11
70
-
71
- workflows:
72
- version: 2
73
- build_all:
74
- jobs:
75
- - build-node18:
76
- <<: *filter-run-always
77
- - build-node20:
78
- <<: *filter-run-always
79
- - deploy:
80
- requires:
81
- - build-node18
82
- - build-node20
83
- <<: *filter-run-on-master-and-version-tag-only
package/.editorconfig DELETED
@@ -1,5 +0,0 @@
1
- [*.{js,jsx,ts,tsx,cs, json, md, yml, yaml, html, css, scss, less, graphql, mdx, vue, svelte, php, py, rb, rs, toml, go, java, sh, fish, bash, zsh, ksh, sql, tex, r, rmd, dart, xml, cfml, cfc, cfm, tcl, liquid, lisp, el, vue, svelte, swift, scala, groovy, kt, kts, clj, cljs, edn, hs, lhs, lua, moon, ml, mli, mll, mly, pascal, pas, pl, pm, t, pod, php, php3, php4, php5, php6, php7, php8, phtml, ps1, psm1, py, py3, pyi, pyx, r, rkt, rpy, rpy2, rb, rs, rli}]
2
- indent_style = space
3
- indent_size = 4
4
- trim_trailing_whitespace = true
5
- insert_final_newline = true
@@ -1,11 +0,0 @@
1
- # To get started with Dependabot version updates, you'll need to specify which
2
- # package ecosystems to update and where the package manifests are located.
3
- # Please see the documentation for all configuration options:
4
- # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5
-
6
- version: 2
7
- updates:
8
- - package-ecosystem: 'npm'
9
- directory: '/' # Location of package manifests
10
- schedule:
11
- interval: 'weekly'
package/.nvmrc DELETED
@@ -1 +0,0 @@
1
- 20.11
package/.prettierrc DELETED
@@ -1,11 +0,0 @@
1
- {
2
- "arrowParens": "always",
3
- "endOfLine": "auto",
4
- "printWidth": 120,
5
- "semi": false,
6
- "singleAttributePerLine": false,
7
- "singleQuote": true,
8
- "tabWidth": 4,
9
- "trailingComma": "none",
10
- "useTabs": false
11
- }
@@ -1,3 +0,0 @@
1
- {
2
- "recommendations": ["esbenp.prettier-vscode"]
3
- }
@@ -1,10 +0,0 @@
1
- {
2
- // exclude .vscode, .github and node_modules from the explorer
3
- "files.exclude": {
4
- "**/.vscode": true,
5
- "**/.github": true,
6
- "**/node_modules": true,
7
- "**/dist": true,
8
- ".circleci": true
9
- }
10
- }
package/eslint.config.mjs DELETED
@@ -1,32 +0,0 @@
1
- import jsdoc from 'eslint-plugin-jsdoc'
2
- import tsParser from '@typescript-eslint/parser'
3
-
4
- export default [
5
- {
6
- files: ['**/*.js'],
7
- plugins: {
8
- jsdoc: jsdoc
9
- },
10
- rules: {
11
- 'jsdoc/require-description': 'error',
12
- 'jsdoc/check-values': 'error'
13
- }
14
- },
15
- {
16
- files: ['**/*.ts'],
17
- languageOptions: {
18
- parser: tsParser,
19
- parserOptions: {
20
- ecmaVersion: 'latest',
21
- sourceType: 'module'
22
- }
23
- },
24
- plugins: {
25
- jsdoc: jsdoc
26
- },
27
- rules: {
28
- 'jsdoc/require-description': 'error',
29
- 'jsdoc/check-values': 'error'
30
- }
31
- }
32
- ]
package/tsconfig.json DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ESNext",
4
- "module": "NodeNext",
5
- "experimentalDecorators": true,
6
- "emitDecoratorMetadata": true,
7
- "moduleResolution": "NodeNext",
8
- "outDir": "dist"
9
- }
10
- }