@ti-engine/web-framework 1.18.1 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  This document will contain the list of changes made to the framework. The format is based on the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
4
4
 
5
+ ## Version 1.19.0
6
+
7
+ `/static` was served with `max-age=1y, immutable` for every consumer of the framework. `immutable` is a promise that the bytes behind a URL will never change, and browsers honour it so completely that not even a manual reload revalidates — so the promise is only true for a content-addressed URL (`app.a1b2c3.css`). None of the framework's own assets are named that way (`/static/scripts/ti-framework.js`, the theme sheets), which made this an unsafe default that shipped to npm: a deployed CSS or JS fix would never reach anyone who had already visited, for up to a year, with no way to tell them otherwise. The standalone author's site had worked around it privately by fingerprinting its own asset URLs; every other consumer still inherited the bug.
8
+
9
+ * fix(web-server)!: the default `/static` cache policy is now `public, max-age=0, must-revalidate` instead of `max-age=1y, immutable`, so a deployed asset change actually reaches a returning visitor. `express.static` still attaches an `ETag`/`Last-Modified`, so a revalidation of an unchanged asset is answered with a `304` — headers, no body. **A consumer whose asset filenames are content-addressed should opt back in** with `staticCache: { maxAge: 31536000, immutable: true }`; one that appends a content hash to its asset URLs (rather than to the filenames) is equally entitled to it
10
+ * feat(web-server): add the `staticCache` configuration block — `maxAge` (whole **seconds**, mapping 1:1 onto the `Cache-Control` directive; an express-style `"1y"` duration string is reported rather than silently reinterpreted as milliseconds), `immutable`, and `immutablePaths` (path prefixes served long-lived and `immutable` regardless of the other two). `immutable` combined with a `maxAge` of 0 is a contradiction and is dropped with a warning, so a half-configured deployment costs a revalidation rather than a year of unreachable assets
11
+ * feat(web-server): `staticCache.immutablePaths` defaults to `[ "/fonts/" ]` — a released `.woff2` is an artifact rather than something edited in place, and its filename already carries the family, weight and style. Configurable, and clearable with an explicitly empty array, because that is a statement about how a given deployment manages its font files
12
+ * feat(web-framework): add the `TI_WEB_STATIC_MAX_AGE`, `TI_WEB_STATIC_IMMUTABLE`, and `TI_WEB_STATIC_IMMUTABLE_PATHS` environment overrides, so the cache policy is settable per deployment like every other web setting; `TI_WEB_STATIC_IMMUTABLE_PATHS` **replaces** the array, and an explicitly empty value means no long-lived paths
13
+ * refactor(web-server): the `/static` mounts write `Cache-Control` per file through `express.static`'s `setHeaders` rather than its `maxAge`/`immutable` options, since the policy is no longer uniform across the tree; the decision itself lives in the pure, unit-tested `TiWebServer.resolveStaticCachePolicy()` (config → policy + warnings, which the caller logs) and `TiWebServer.staticCacheControlFor()` (policy + file → header). The `staticCache` defaults deliberately live on the class rather than in `web-server.json`, because the constructor's `_.merge` merges arrays by index and a consumer's empty `immutablePaths` could otherwise never clear a default entry
14
+ * docs(web-framework): document the `staticCache` block, the revalidating default and the reasoning behind it, and the fingerprinting opt-in in the README
15
+ * build(release): bump package version from `1.18.1` to `1.19.0`
16
+
5
17
  ## Version 1.18.1
6
18
 
7
19
  Enabling Azure SSO took the instance down at startup: the OAuth2 callback was registered by handing the configured callback value straight to Express as a route path, and the installation docs tell operators to set that value to the full absolute URL registered with the identity provider. Express 5 parses route patterns with path-to-regexp v8, where `:` opens a parameter name — so `https://host/login/azure-callback` throws `Missing parameter name at index 6` and the web server never starts. Google was affected identically, which also made this a prerequisite for the competence Cloud Run deployment, whose `deploy.sh` patches in an absolute callback URL (CA-97).
package/README.md CHANGED
@@ -20,9 +20,42 @@ The web server configuration (host, port, TLS, cookies, etc.) is normally provid
20
20
  * `TI_WEB_AUTH_METHODS` (comma-separated) **replaces** the enabled authentication methods (`auth.enabledMethods`), e.g. `openid-google` or `local,openid-google`.
21
21
  * `TI_WEB_AUTH_ADMINS` (comma-separated) **replaces** the admin allowlist (`auth.admins`). Entries are matched against the session user's user ID, username or email, so an OpenID deployment lists emails. An explicitly empty value means *no admins*.
22
22
  * `TI_WEB_TRUSTED_ORIGINS` (comma-separated) **replaces** the trusted request origins (`trustedOrigins`) — needed behind proxies that do not present the real external origin.
23
+ * `TI_WEB_STATIC_MAX_AGE` (whole seconds) overrides `staticCache.maxAge`. See [Static asset caching](#static-asset-caching).
24
+ * `TI_WEB_STATIC_IMMUTABLE` (`true`/`false`) overrides `staticCache.immutable`.
25
+ * `TI_WEB_STATIC_IMMUTABLE_PATHS` (comma-separated) **replaces** `staticCache.immutablePaths`. An explicitly empty value means *no long-lived paths*.
23
26
 
24
27
  OpenID Connect providers are configured with their own variables — `TI_AZURE_AUTH_CLIENT_ID` / `TI_AZURE_AUTH_CLIENT_SECRET` / `TI_AZURE_AUTH_CALLBACK_URL` / `TI_AZURE_AUTH_DISCOVERY_URL`, and the `TI_GCLOUD_AUTH_*` equivalents. A callback URL may be given either as the full absolute URL registered with the provider (`https://your-host/login/azure-callback`) or as a path (`/login/azure-callback`): the server always listens on the path, while the `redirect_uri` sent to the provider is the absolute value verbatim if one was configured, and otherwise assembled from the request's forwarded protocol/host.
25
28
 
29
+ ## Static asset caching
30
+
31
+ Everything under `/static` is served with a `Cache-Control` policy configured by the `staticCache` block:
32
+
33
+ ```json
34
+ {
35
+ "staticCache": {
36
+ "maxAge": 0,
37
+ "immutable": false,
38
+ "immutablePaths": [ "/fonts/" ]
39
+ }
40
+ }
41
+ ```
42
+
43
+ * `maxAge` — the `max-age` in **whole seconds** (not an express-style `"1y"` duration string; one is rejected with a warning rather than reinterpreted as milliseconds). `0`, the default, emits `public, max-age=0, must-revalidate`.
44
+ * `immutable` — adds the `immutable` directive. Defaults to `false`, and is **ignored with a warning when `maxAge` is 0**, since a response that is stale on arrival cannot also promise never to change.
45
+ * `immutablePaths` — path prefixes under `/static` (matched case-sensitively, on a path-segment boundary) served `public, max-age=31536000, immutable` regardless of the two settings above. Defaults to `[ "/fonts/" ]`. This **replaces** rather than merges, so `[]` means no long-lived paths.
46
+
47
+ **The default revalidates, and that is deliberate.** `immutable` tells a browser the bytes behind a URL will never change, and browsers honour it so completely that not even a manual reload revalidates. On a stable filename — which is what the framework's own assets use (`/static/scripts/ti-framework.js`, the theme sheets) — the promise is false, and a deployed CSS or JS fix simply never reaches anyone who has already visited, for up to a year, with no way to tell them otherwise. Revalidating costs a conditional request per asset, answered with a `304` from the `ETag`/`Last-Modified` that `express.static` still attaches — headers, no body.
48
+
49
+ **Opt back into `immutable` once your filenames are content-addressed.** If your build emits `app.a1b2c3.css`, or your application appends a content hash to each asset URL, the promise becomes true and there is real value in making it:
50
+
51
+ ```json
52
+ {
53
+ "staticCache": { "maxAge": 31536000, "immutable": true }
54
+ }
55
+ ```
56
+
57
+ Fonts are the default exception because a released `.woff2` is an artifact rather than something edited in place, and its filename already carries the family, weight and style. If that is not how a given deployment manages its fonts, clear the list.
58
+
26
59
  ## Configure HTTPS for development
27
60
 
28
61
  Use the `mkcert` tool to create a certificate for development.
@@ -1,37 +1,37 @@
1
- <div x-id="['component-flyout']"
2
- x-data="tiComponentSidebarFlyout('{ti-config-key}')"
3
- class="ti-sidebar-flyout-container">
4
- <!--
5
- Trigger slot. The placeholder consumer can replace this with any custom button — typically a user avatar
6
- or app menu trigger. The trigger MUST set x-ref="flyoutButton" and call toggle() to integrate with the
7
- encapsulated flyout state. If the consumer omits inner content, this default icon button is used.
8
- -->
9
- <ti-slot>
10
- <button x-on:click.stop="toggle()"
11
- x-on:ti-close-all-flyout="close()"
12
- x-bind:aria-expanded="isOpen"
13
- x-bind:aria-controls="$id('component-flyout')"
14
- x-bind:aria-label="menuTitle"
15
- x-ref="flyoutButton"
16
- type="button" class="ti-sidebar-button">
17
- <span class="ti-icon" x-bind:class="icon" aria-hidden="true"></span>
18
- </button>
19
- </ti-slot>
20
- <div x-on:click.outside="close()"
21
- x-bind:id="$id('component-flyout')"
22
- x-show="isOpen"
23
- x-ref="flyoutPanel"
24
- role="menu" class="ti-sidebar-flyout">
25
- <template x-for="(buttonConfig, index) in buttonConfigs">
26
- <button x-on:click="close()"
27
- x-bind:hx-get="(!buttonConfig.action.method || buttonConfig.action.method === 'get') ? buttonConfig.action.href : null"
28
- x-bind:hx-post="(buttonConfig.action.method && buttonConfig.action.method === 'post') ? buttonConfig.action.href : null"
29
- x-bind:hx-target="buttonConfig.action.target"
30
- x-bind:hx-swap="buttonConfig.action.swap"
31
- hx-push-url="true"
32
- type="button" class="ti-sidebar-flyout-item">
33
- <span class="ti-icon" x-bind:class="buttonConfig.icon" aria-hidden="true"></span><span x-text="buttonConfig.title"></span>
34
- </button>
35
- </template>
36
- </div>
1
+ <div x-id="['component-flyout']"
2
+ x-data="tiComponentSidebarFlyout('{ti-config-key}')"
3
+ class="ti-sidebar-flyout-container">
4
+ <!--
5
+ Trigger slot. The placeholder consumer can replace this with any custom button — typically a user avatar
6
+ or app menu trigger. The trigger MUST set x-ref="flyoutButton" and call toggle() to integrate with the
7
+ encapsulated flyout state. If the consumer omits inner content, this default icon button is used.
8
+ -->
9
+ <ti-slot>
10
+ <button x-on:click.stop="toggle()"
11
+ x-on:ti-close-all-flyout="close()"
12
+ x-bind:aria-expanded="isOpen"
13
+ x-bind:aria-controls="$id('component-flyout')"
14
+ x-bind:aria-label="menuTitle"
15
+ x-ref="flyoutButton"
16
+ type="button" class="ti-sidebar-button">
17
+ <span class="ti-icon" x-bind:class="icon" aria-hidden="true"></span>
18
+ </button>
19
+ </ti-slot>
20
+ <div x-on:click.outside="close()"
21
+ x-bind:id="$id('component-flyout')"
22
+ x-show="isOpen"
23
+ x-ref="flyoutPanel"
24
+ role="menu" class="ti-sidebar-flyout">
25
+ <template x-for="(buttonConfig, index) in buttonConfigs">
26
+ <button x-on:click="close()"
27
+ x-bind:hx-get="(!buttonConfig.action.method || buttonConfig.action.method === 'get') ? buttonConfig.action.href : null"
28
+ x-bind:hx-post="(buttonConfig.action.method && buttonConfig.action.method === 'post') ? buttonConfig.action.href : null"
29
+ x-bind:hx-target="buttonConfig.action.target"
30
+ x-bind:hx-swap="buttonConfig.action.swap"
31
+ hx-push-url="true"
32
+ type="button" class="ti-sidebar-flyout-item">
33
+ <span class="ti-icon" x-bind:class="buttonConfig.icon" aria-hidden="true"></span><span x-text="buttonConfig.title"></span>
34
+ </button>
35
+ </template>
36
+ </div>
37
37
  </div>
package/bin/web-server.js CHANGED
@@ -39,6 +39,7 @@ const applyWebConfigEnvOverrides = require( "#web-config-env" );
39
39
  * @property {number} port
40
40
  * @property {string} publicPath
41
41
  * @property {number} requestTimeout
42
+ * @property {SettingsStaticCache} staticCache
42
43
  * @property {string} tlsCertPath
43
44
  * @property {string} tlsKeyPath
44
45
  * @property {boolean} useTLS
@@ -75,6 +76,13 @@ const applyWebConfigEnvOverrides = require( "#web-config-env" );
75
76
  * @property {TiTokenEndpointAuthMethod} [tokenEndpointAuthMethod]
76
77
  */
77
78
 
79
+ /**
80
+ * @typedef {Object} SettingsStaticCache
81
+ * @property {number} maxAge The `max-age` for `/static` responses, in SECONDS (not a duration string). `0` means every use is revalidated.
82
+ * @property {boolean} immutable Whether to add `immutable`. Only correct when the `/static` filenames are content-addressed.
83
+ * @property {string[]} immutablePaths Path prefixes under `/static` that are served long-lived and `immutable` regardless of the two settings above.
84
+ */
85
+
78
86
  /**
79
87
  * @typedef {Object} SettingsCookies
80
88
  * @property {string} secret
@@ -310,8 +318,20 @@ class TiWebServer extends ServiceConsumer {
310
318
  this.#webServer.use( "/.well-known", express.static( path.join( this.#staticContentPaths[ 0 ], ".well-known" ), { dotfiles: "allow" } ) );
311
319
 
312
320
  // Static content routes are registered in reverse order to ensure that custom assets can override the default ones and be served first:
321
+ const staticCachePolicy = TiWebServer.resolveStaticCachePolicy( this.serviceConfig.staticCache );
322
+ staticCachePolicy.warnings.forEach( ( warning ) => logger.log( warning, logger.logSeverity.WARNING ) );
313
323
  _.forEachRight( this.#staticContentPaths, ( staticContentPath ) => {
314
- this.#webServer.use( "/static", express.static( staticContentPath, { maxAge: "1y", immutable: true } ) );
324
+ // `Cache-Control` is written per file rather than through express.static's `maxAge`/`immutable`
325
+ // options, because the policy is not uniform across the tree (see resolveStaticCachePolicy). A
326
+ // header set here wins: `send` emits its "headers" event BEFORE its own `Cache-Control` block,
327
+ // which then skips a header that is already present. `ETag`/`Last-Modified` are still added by
328
+ // `send`, so the revalidating default costs a conditional request answered with a 304, not a
329
+ // re-download.
330
+ this.#webServer.use( "/static", express.static( staticContentPath, {
331
+ setHeaders: ( response, filePath ) => {
332
+ response.setHeader( "Cache-Control", TiWebServer.staticCacheControlFor( staticContentPath, filePath, staticCachePolicy ) );
333
+ }
334
+ } ) );
315
335
  } );
316
336
 
317
337
  // Set up the web application routes:
@@ -627,6 +647,178 @@ class TiWebServer extends ServiceConsumer {
627
647
  */
628
648
  static #REGISTRABLE_METHODS = new Set( [ "get", "post", "put", "patch", "delete", "options", "head", "all" ] );
629
649
 
650
+ /**
651
+ * The default `/static` cache policy: revalidate every use, with a long-lived exception for web fonts.
652
+ * <br/>
653
+ * The default used to be `max-age=1y, immutable`, which was wrong for every consumer that does not hash its asset
654
+ * filenames — and none of them do by default, since the framework's own assets ship under stable names
655
+ * (`/static/scripts/ti-framework.js`, the theme sheets, …). `immutable` promises that the bytes behind THIS URL
656
+ * will never change, and browsers honour it so completely that not even a manual reload revalidates: a deployed
657
+ * CSS or JS fix would simply never reach anyone who had already visited, for up to a year, with no way to tell
658
+ * them otherwise. Revalidating is the only default that is true for a stable filename; `send` still attaches an
659
+ * `ETag`/`Last-Modified`, so the cost is a conditional request answered with a 304, not a re-download.
660
+ * <br/>
661
+ * A consumer that fingerprints its filenames (`app.a1b2c3.css`) makes the promise true and should opt back in via
662
+ * `staticCache: { maxAge: 31536000, immutable: true }`.
663
+ * <br/>
664
+ * NOTE: These defaults deliberately live here rather than in `web-server.json`, because the constructor merges the
665
+ * service config with `_.merge`, which merges arrays BY INDEX — a consumer's `immutablePaths: []` could then never
666
+ * clear a default entry. Absent from the config file, an explicitly empty array means exactly that.
667
+ *
668
+ * @type {Object}
669
+ * @private
670
+ */
671
+ static #STATIC_CACHE_DEFAULTS = Object.freeze( {
672
+ maxAge: 0,
673
+ immutable: false,
674
+ // Fonts are the one genuinely content-addressed-in-practice class under `/static`: a released `.woff2` is an
675
+ // artifact, not something that gets edited in place, and its filename already carries the family, weight and
676
+ // style. Configurable, because that is a statement about how a given deployment manages its font files.
677
+ immutablePaths: Object.freeze( [ "/fonts/" ] )
678
+ } );
679
+
680
+ /**
681
+ * The `max-age` applied to a path matched by `staticCache.immutablePaths`, in seconds (one year — the longest
682
+ * value any cache treats as meaningful, and the conventional pairing for `immutable`).
683
+ *
684
+ * @type {number}
685
+ * @private
686
+ */
687
+ static #IMMUTABLE_MAX_AGE = 31536000;
688
+
689
+ /**
690
+ * Normalizes an `immutablePaths` entry to a rooted, slash-terminated prefix (`fonts` -> `/fonts/`), or null when
691
+ * it is not usable. The trailing slash is what keeps `/fonts` from also matching `/fonts-legacy/a.woff2`.
692
+ *
693
+ * @method
694
+ * @static
695
+ * @param {string} entry
696
+ * @returns {string|null}
697
+ * @private
698
+ */
699
+ static #normalizeImmutablePath( entry ) {
700
+ if ( typeof entry !== "string" || entry.trim() === "" ) {
701
+ return null;
702
+ }
703
+ const trimmed = entry.trim();
704
+ const rooted = trimmed.startsWith( "/" ) ? trimmed : "/" + trimmed;
705
+ return rooted.endsWith( "/" ) ? rooted : rooted + "/";
706
+ }
707
+
708
+ /**
709
+ * Derives the served path of a static file (the part after the `/static` mount, always slash-separated) from the
710
+ * directory it is served out of and its absolute location on disk. A file resolving outside the root yields a
711
+ * `/../`-prefixed path, which matches no normalized prefix and therefore falls back to the default policy.
712
+ *
713
+ * @method
714
+ * @static
715
+ * @param {string} rootPath
716
+ * @param {string} filePath
717
+ * @returns {string}
718
+ * @private
719
+ */
720
+ static #toServedPath( rootPath, filePath ) {
721
+ // Split on the platform separator only: on POSIX a backslash is a legal filename character, not a delimiter.
722
+ return "/" + path.relative( String( rootPath || "" ), String( filePath || "" ) ).split( path.sep ).join( "/" );
723
+ }
724
+
725
+ /**
726
+ * Resolves a `staticCache` configuration block into the policy the `/static` mounts apply, filling in
727
+ * {@link TiWebServer.#STATIC_CACHE_DEFAULTS} per key and rejecting values that cannot be honored. Pure: problems
728
+ * are returned as `warnings` rather than logged, so the caller decides how to surface them and a test can assert
729
+ * on them. Static and exposed for unit testing — not part of the customization surface.
730
+ * <br/>
731
+ * `maxAge` is a whole number of SECONDS, mapping 1:1 onto the `Cache-Control` directive — express's `"1y"`-style
732
+ * duration strings are NOT accepted, and are reported rather than silently reinterpreted as milliseconds.
733
+ * <br/>
734
+ * `immutable` is dropped (with a warning) when `maxAge` is 0, because a response that is stale on arrival yet
735
+ * promises never to change is a contradiction. Dropping it fails safe: the misconfiguration costs a revalidation,
736
+ * not a year of unreachable assets.
737
+ *
738
+ * @method
739
+ * @static
740
+ * @param {SettingsStaticCache} [staticCache] The configured block, if any.
741
+ * @returns {{maxAge: number, immutable: boolean, immutablePaths: string[], warnings: string[]}}
742
+ * @public
743
+ */
744
+ static resolveStaticCachePolicy( staticCache ) {
745
+ const defaults = TiWebServer.#STATIC_CACHE_DEFAULTS;
746
+ const config = _.isObjectLike( staticCache ) ? staticCache : {};
747
+ const warnings = [];
748
+
749
+ let maxAge = defaults.maxAge;
750
+ if ( config.maxAge !== undefined ) {
751
+ if ( Number.isInteger( config.maxAge ) && config.maxAge >= 0 ) {
752
+ maxAge = config.maxAge;
753
+ } else {
754
+ warnings.push( `Ignored an invalid 'staticCache.maxAge' value of '${ config.maxAge }'; it must be a whole, non-negative number of seconds (a duration string such as '1y' is not accepted). Using ${ defaults.maxAge } instead.` );
755
+ }
756
+ }
757
+
758
+ let immutable = defaults.immutable;
759
+ if ( config.immutable !== undefined ) {
760
+ if ( typeof config.immutable === "boolean" ) {
761
+ immutable = config.immutable;
762
+ } else {
763
+ warnings.push( `Ignored a non-boolean 'staticCache.immutable' value of '${ config.immutable }'. Using ${ defaults.immutable } instead.` );
764
+ }
765
+ }
766
+ if ( immutable === true && maxAge === 0 ) {
767
+ warnings.push( `Ignored 'staticCache.immutable' because 'staticCache.maxAge' is 0 — a response that is stale on arrival cannot also promise never to change. Set a positive 'staticCache.maxAge' (and hash your asset filenames) to serve '/static' as immutable.` );
768
+ immutable = false;
769
+ }
770
+
771
+ let immutablePaths = defaults.immutablePaths.slice();
772
+ if ( config.immutablePaths !== undefined ) {
773
+ if ( Array.isArray( config.immutablePaths ) ) {
774
+ immutablePaths = [];
775
+ config.immutablePaths.forEach( ( entry ) => {
776
+ const normalized = TiWebServer.#normalizeImmutablePath( entry );
777
+ if ( normalized === null ) {
778
+ warnings.push( `Ignored an invalid 'staticCache.immutablePaths' entry of type '${ typeof entry }'; expected a non-empty path prefix such as '/fonts/'.` );
779
+ } else {
780
+ immutablePaths.push( normalized );
781
+ }
782
+ } );
783
+ } else {
784
+ warnings.push( `Ignored a non-array 'staticCache.immutablePaths' value of type '${ typeof config.immutablePaths }'. Using the default [ ${ defaults.immutablePaths.join( ", " ) } ] instead.` );
785
+ }
786
+ }
787
+
788
+ return { maxAge: maxAge, immutable: immutable, immutablePaths: immutablePaths, warnings: warnings };
789
+ }
790
+
791
+ /**
792
+ * Builds the `Cache-Control` value for one static file: the long-lived immutable policy when its served path sits
793
+ * under a configured `immutablePaths` prefix (matched case-sensitively, so a case mismatch falls back to the safe
794
+ * side), otherwise the policy's own `maxAge`/`immutable`. A `maxAge` of 0 is emitted as an explicit
795
+ * `must-revalidate` rather than a bare `max-age=0`, matching what the sibling `web-content` package serves.
796
+ * Pure and static; exposed for unit testing — not part of the customization surface.
797
+ *
798
+ * @method
799
+ * @static
800
+ * @param {string} rootPath The directory this `/static` mount serves.
801
+ * @param {string} filePath The absolute path of the file being served.
802
+ * @param {Object} policy A policy as returned by {@link TiWebServer.resolveStaticCachePolicy}.
803
+ * @returns {string}
804
+ * @public
805
+ */
806
+ static staticCacheControlFor( rootPath, filePath, policy ) {
807
+ const resolved = _.isObjectLike( policy ) ? policy : {};
808
+ const immutablePaths = Array.isArray( resolved.immutablePaths ) ? resolved.immutablePaths : [];
809
+ const servedPath = TiWebServer.#toServedPath( rootPath, filePath );
810
+
811
+ if ( immutablePaths.some( ( prefix ) => servedPath.startsWith( prefix ) ) === true ) {
812
+ return `public, max-age=${ TiWebServer.#IMMUTABLE_MAX_AGE }, immutable`;
813
+ }
814
+
815
+ const maxAge = ( Number.isInteger( resolved.maxAge ) && resolved.maxAge >= 0 ) ? resolved.maxAge : 0;
816
+ if ( maxAge === 0 ) {
817
+ return "public, max-age=0, must-revalidate";
818
+ }
819
+ return ( resolved.immutable === true ) ? `public, max-age=${ maxAge }, immutable` : `public, max-age=${ maxAge }`;
820
+ }
821
+
630
822
  /**
631
823
  * Normalizes an HTTP method to a lower-case Express routing verb, or returns null if it is not a supported,
632
824
  * registrable verb. Anything that is not a string is rejected outright rather than coerced — otherwise a value
@@ -1,68 +1,85 @@
1
- /*
2
- * The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
3
- * Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
4
- * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
5
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
6
- * You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
7
- */
8
-
9
- "use strict";
10
-
11
- const tools = require( "@ti-engine/core/tools" );
12
-
13
- /**
14
- * Applies TI_WEB_* environment-variable overrides onto an (already-merged) web server configuration object.
15
- * Each override is applied ONLY when its environment variable is defined, so an absent variable leaves the
16
- * configured/default value untouched (fully backward compatible). This gives ti-engine web servers 12-factor,
17
- * container-friendly control over network binding, TLS, the session cookie secret, the enabled authentication
18
- * methods, the admin allowlist, and the trusted request origins without editing config files. Note `TI_WEB_AUTH_METHODS`,
19
- * `TI_WEB_AUTH_ADMINS`, and `TI_WEB_TRUSTED_ORIGINS` fully REPLACE their config arrays (`auth.enabledMethods` / `auth.admins` / `trustedOrigins`) rather than
20
- * merging — the config-file merge is by-index and cannot cleanly override an array.
21
- *
22
- * @method
23
- * @param {Object} config The web server configuration to augment (mutated in place and returned).
24
- * @param {Object} [env=process.env] The environment source (injectable for testing).
25
- * @returns {Object} The same config object, with any present overrides applied.
26
- * @public
27
- */
28
- function applyWebConfigEnvOverrides( config, env = process.env ) {
29
- if ( !config || typeof config !== "object" ) {
30
- return config;
31
- }
32
- if ( env.TI_WEB_HOST !== undefined ) {
33
- config.host = env.TI_WEB_HOST;
34
- }
35
- if ( env.TI_WEB_PORT !== undefined ) {
36
- const port = Number( env.TI_WEB_PORT );
37
- if ( Number.isInteger( port ) ) {
38
- config.port = port;
39
- }
40
- }
41
- if ( env.TI_WEB_USE_TLS !== undefined ) {
42
- config.useTLS = tools.toBool( env.TI_WEB_USE_TLS );
43
- }
44
- if ( env.TI_WEB_TLS_CERT_PATH !== undefined ) {
45
- config.tlsCertPath = env.TI_WEB_TLS_CERT_PATH;
46
- }
47
- if ( env.TI_WEB_TLS_KEY_PATH !== undefined ) {
48
- config.tlsKeyPath = env.TI_WEB_TLS_KEY_PATH;
49
- }
50
- if ( env.TI_WEB_COOKIE_SECRET !== undefined ) {
51
- config.cookies = config.cookies || {};
52
- config.cookies.secret = env.TI_WEB_COOKIE_SECRET;
53
- }
54
- if ( env.TI_WEB_AUTH_METHODS !== undefined ) {
55
- config.auth = config.auth || {};
56
- config.auth.enabledMethods = env.TI_WEB_AUTH_METHODS.split( "," ).map( ( method ) => method.trim() ).filter( ( method ) => method.length > 0 );
57
- }
58
- if ( env.TI_WEB_AUTH_ADMINS !== undefined ) {
59
- config.auth = config.auth || {};
60
- config.auth.admins = env.TI_WEB_AUTH_ADMINS.split( "," ).map( ( entry ) => entry.trim() ).filter( ( entry ) => entry.length > 0 );
61
- }
62
- if ( env.TI_WEB_TRUSTED_ORIGINS !== undefined ) {
63
- config.trustedOrigins = env.TI_WEB_TRUSTED_ORIGINS.split( "," ).map( ( origin ) => origin.trim() ).filter( ( origin ) => origin.length > 0 );
64
- }
65
- return config;
66
- }
67
-
68
- module.exports = applyWebConfigEnvOverrides;
1
+ /*
2
+ * The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
3
+ * Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
4
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
5
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
6
+ * You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
7
+ */
8
+
9
+ "use strict";
10
+
11
+ const tools = require( "@ti-engine/core/tools" );
12
+
13
+ /**
14
+ * Applies TI_WEB_* environment-variable overrides onto an (already-merged) web server configuration object.
15
+ * Each override is applied ONLY when its environment variable is defined, so an absent variable leaves the
16
+ * configured/default value untouched (fully backward compatible). This gives ti-engine web servers 12-factor,
17
+ * container-friendly control over network binding, TLS, the session cookie secret, the enabled authentication
18
+ * methods, the admin allowlist, the trusted request origins, and the `/static` cache policy without editing config files. Note `TI_WEB_AUTH_METHODS`,
19
+ * `TI_WEB_AUTH_ADMINS`, `TI_WEB_TRUSTED_ORIGINS`, and `TI_WEB_STATIC_IMMUTABLE_PATHS` fully REPLACE their config arrays (`auth.enabledMethods` / `auth.admins` / `trustedOrigins` / `staticCache.immutablePaths`) rather than
20
+ * merging — the config-file merge is by-index and cannot cleanly override an array.
21
+ *
22
+ * @method
23
+ * @param {Object} config The web server configuration to augment (mutated in place and returned).
24
+ * @param {Object} [env=process.env] The environment source (injectable for testing).
25
+ * @returns {Object} The same config object, with any present overrides applied.
26
+ * @public
27
+ */
28
+ function applyWebConfigEnvOverrides( config, env = process.env ) {
29
+ if ( !config || typeof config !== "object" ) {
30
+ return config;
31
+ }
32
+ if ( env.TI_WEB_HOST !== undefined ) {
33
+ config.host = env.TI_WEB_HOST;
34
+ }
35
+ if ( env.TI_WEB_PORT !== undefined ) {
36
+ const port = Number( env.TI_WEB_PORT );
37
+ if ( Number.isInteger( port ) ) {
38
+ config.port = port;
39
+ }
40
+ }
41
+ if ( env.TI_WEB_USE_TLS !== undefined ) {
42
+ config.useTLS = tools.toBool( env.TI_WEB_USE_TLS );
43
+ }
44
+ if ( env.TI_WEB_TLS_CERT_PATH !== undefined ) {
45
+ config.tlsCertPath = env.TI_WEB_TLS_CERT_PATH;
46
+ }
47
+ if ( env.TI_WEB_TLS_KEY_PATH !== undefined ) {
48
+ config.tlsKeyPath = env.TI_WEB_TLS_KEY_PATH;
49
+ }
50
+ if ( env.TI_WEB_COOKIE_SECRET !== undefined ) {
51
+ config.cookies = config.cookies || {};
52
+ config.cookies.secret = env.TI_WEB_COOKIE_SECRET;
53
+ }
54
+ if ( env.TI_WEB_AUTH_METHODS !== undefined ) {
55
+ config.auth = config.auth || {};
56
+ config.auth.enabledMethods = env.TI_WEB_AUTH_METHODS.split( "," ).map( ( method ) => method.trim() ).filter( ( method ) => method.length > 0 );
57
+ }
58
+ if ( env.TI_WEB_AUTH_ADMINS !== undefined ) {
59
+ config.auth = config.auth || {};
60
+ config.auth.admins = env.TI_WEB_AUTH_ADMINS.split( "," ).map( ( entry ) => entry.trim() ).filter( ( entry ) => entry.length > 0 );
61
+ }
62
+ if ( env.TI_WEB_TRUSTED_ORIGINS !== undefined ) {
63
+ config.trustedOrigins = env.TI_WEB_TRUSTED_ORIGINS.split( "," ).map( ( origin ) => origin.trim() ).filter( ( origin ) => origin.length > 0 );
64
+ }
65
+ if ( env.TI_WEB_STATIC_MAX_AGE !== undefined ) {
66
+ // Seconds, matching the `Cache-Control` directive itself. A non-integer is left to the config value rather
67
+ // than coerced, exactly as TI_WEB_PORT is — `TiWebServer.resolveStaticCachePolicy` reports the bad value.
68
+ const maxAge = Number( env.TI_WEB_STATIC_MAX_AGE );
69
+ if ( Number.isInteger( maxAge ) && maxAge >= 0 ) {
70
+ config.staticCache = config.staticCache || {};
71
+ config.staticCache.maxAge = maxAge;
72
+ }
73
+ }
74
+ if ( env.TI_WEB_STATIC_IMMUTABLE !== undefined ) {
75
+ config.staticCache = config.staticCache || {};
76
+ config.staticCache.immutable = tools.toBool( env.TI_WEB_STATIC_IMMUTABLE );
77
+ }
78
+ if ( env.TI_WEB_STATIC_IMMUTABLE_PATHS !== undefined ) {
79
+ config.staticCache = config.staticCache || {};
80
+ config.staticCache.immutablePaths = env.TI_WEB_STATIC_IMMUTABLE_PATHS.split( "," ).map( ( prefix ) => prefix.trim() ).filter( ( prefix ) => prefix.length > 0 );
81
+ }
82
+ return config;
83
+ }
84
+
85
+ module.exports = applyWebConfigEnvOverrides;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ti-engine/web-framework",
3
- "version": "1.18.1",
3
+ "version": "1.19.0",
4
4
  "description": "A web-framework based on the ti-engine. It provides a customizable ready-to-use web-server microservice and a set of tools for creating web applications. NOTICE: This is still a work in progress and the full architecture, design, and functionality are not available!",
5
5
  "author": "Boris Kostadinov <kostadinov.boris@gmail.com>",
6
6
  "license": "GPL-3.0-or-later",
@@ -33,7 +33,7 @@
33
33
  "cookie-parser": "^1.4.7",
34
34
  "express": "^5.2.1",
35
35
  "express-session": "^1.19.0",
36
- "helmet": "^8.2.0",
36
+ "helmet": "^8.3.0",
37
37
  "lodash": "^4.18.1",
38
38
  "openid-client": "^6.8.4"
39
39
  },