From ec1528682dd8f916f4b02e3bbd5a0973f8703f50 Mon Sep 17 00:00:00 2001 From: Frankie Moran Date: Mon, 23 Feb 2026 11:11:03 +0000 Subject: [PATCH 01/11] Phase 1: Fix Packaging Correctness (Highest Priority) --- .github/workflows/npm_build.yml | 1 - README.md | 8 +- package.json | 103 +++++++++++++----------- src/app-insights/index.ts | 2 +- src/index.ts | 18 ++--- src/interfaces/index.ts | 20 ++--- src/interfaces/transfer-server-state.ts | 4 +- src/launch-darkly/index.ts | 2 +- src/proxy/index.ts | 2 +- src/routes/index.ts | 22 ++--- src/services/index.ts | 2 +- src/services/opal-user-service.ts | 2 +- src/session.d.ts | 2 +- src/session/index.ts | 4 +- src/session/session-expiry/index.ts | 2 +- src/sso/index.ts | 10 +-- src/sso/sso-authenticated.ts | 2 +- src/sso/sso-login-callback.ts | 6 +- src/stubs/sso/index.ts | 10 +-- src/stubs/sso/sso-authenticated.stub.ts | 2 +- src/stubs/sso/sso-logout.stub.ts | 2 +- src/utils/index.ts | 2 +- tsconfig.json | 8 +- 23 files changed, 119 insertions(+), 117 deletions(-) diff --git a/.github/workflows/npm_build.yml b/.github/workflows/npm_build.yml index f3f4cbd..4c7dc78 100644 --- a/.github/workflows/npm_build.yml +++ b/.github/workflows/npm_build.yml @@ -84,7 +84,6 @@ jobs: run: yarn build - name: Publish version (OIDC via npm) - working-directory: dist run: | # Ensure we do NOT publish using token-based auth (classic/granular). unset NODE_AUTH_TOKEN diff --git a/README.md b/README.md index c19f9d1..321cad6 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Run the following to build the project: yarn build ``` -The compiled output will be available in the `dist/` folder. It includes `index.js`, type declarations, and any exported modules listed in the `exports` field. +The compiled output will be available in the `dist/` folder. It includes runtime JS and type declarations for the package exports. ## Switching Between Local and Published Versions @@ -62,11 +62,11 @@ To use a local version of this library during development in another project: yarn build ``` -2. In your consuming project (e.g. `opal-frontend`), ensure you have set an environment variable pointing to the local build: +2. In your consuming project (e.g. `opal-frontend`), ensure you have set an environment variable pointing to this library repository root (not `dist/`): ```bash # In your shell config file (.zshrc, .bash_profile, or .bashrc) - export COMMON_NODE_LIB_PATH="[INSERT PATH TO COMMON NODE LIB DIST FOLDER]" + export COMMON_NODE_LIB_PATH="[INSERT PATH TO COMMON NODE LIB REPOSITORY ROOT]" ``` 3. In the consuming project (e.g. `opal-frontend`), run: @@ -75,7 +75,7 @@ To use a local version of this library during development in another project: yarn import:local:common-node-lib ``` - This will remove the published version and install the local build using the path provided. + This will remove the published version and install the local package using the path provided. 4. To switch back to the published version: ```bash diff --git a/package.json b/package.json index 3940c94..d0320c6 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,23 @@ "version": "0.0.23", "license": "MIT", "description": "Common nodejs library components for opal", + "engines": { + "node": ">=18" + }, "repository": { "type": "git", "url": "https://github.com/hmcts/opal-frontend-common-node-lib" }, - "main": "dist/index", - "types": "dist/index.d.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/**", + "README.md", + "LICENSE" + ], + "sideEffects": false, "scripts": { - "build": " yarn clean && tsc && cp package.json dist/ && cp src/*.d.ts dist/ && cp README.md dist/", + "build": "yarn clean && tsc && cp src/*.d.ts dist/", "clean": "rm -rf dist", "lint": "eslint ./src --ext .ts && yarn prettier", "prettier": "prettier --check \"./src/**/*.{ts,js,json}\"", @@ -37,7 +46,6 @@ "helmet": "^8.0.0", "http-proxy-middleware": "^3.0.0", "luxon": "^3.4.3", - "prettier": "^3.0.3", "redis": "^5.0.0", "session-file-store": "^1.5.0", "xml2js": "^0.6.2" @@ -53,97 +61,94 @@ "@typescript-eslint/parser": "8.56.0", "eslint": "^10.0.0", "eslint-plugin-prettier": "^5.2.6", + "prettier": "^3.0.3", "typescript": "~5.9.0", "typescript-eslint": "^8.30.1" }, "packageManager": "yarn@4.12.0", "exports": { ".": { - "import": "./index.js", - "types": "./index.d.ts" + "import": "./dist/index.js", + "types": "./dist/index.d.ts" }, "./app-insights": { - "import": "./app-insights/index.js", - "types": "./app-insights/index.d.ts" + "import": "./dist/app-insights/index.js", + "types": "./dist/app-insights/index.d.ts" }, "./health": { - "import": "./health/index.js", - "types": "./health/index.d.ts" + "import": "./dist/health/index.js", + "types": "./dist/health/index.d.ts" }, "./helmet": { - "import": "./helmet/index.js", - "types": "./helmet/index.d.ts" + "import": "./dist/helmet/index.js", + "types": "./dist/helmet/index.d.ts" }, "./launch-darkly": { - "import": "./launch-darkly/index.js", - "types": "./launch-darkly/index.d.ts" + "import": "./dist/launch-darkly/index.js", + "types": "./dist/launch-darkly/index.d.ts" }, "./properties-volume": { - "import": "./properties-volume/index.js", - "types": "./properties-volume/index.d.ts" + "import": "./dist/properties-volume/index.js", + "types": "./dist/properties-volume/index.d.ts" }, "./csrf-token": { - "import": "./csrf-token/index.js", - "types": "./csrf-token/index.d.ts" + "import": "./dist/csrf-token/index.js", + "types": "./dist/csrf-token/index.d.ts" }, "./routes": { - "import": "./routes/index.js", - "types": "./routes/index.d.ts" + "import": "./dist/routes/index.js", + "types": "./dist/routes/index.d.ts" }, "./services": { - "import": "./services/index.js", - "types": "./services/index.d.ts" + "import": "./dist/services/index.js", + "types": "./dist/services/index.d.ts" }, "./interfaces": { - "import": "./interfaces/index.js", - "types": "./interfaces/index.d.ts" + "import": "./dist/interfaces/index.js", + "types": "./dist/interfaces/index.d.ts" }, "./interfaces/session-expiry-config": { - "import": "./interfaces/session-expiry-config.js", - "types": "./interfaces/session-expiry-config.d.ts" + "import": "./dist/interfaces/session-expiry-config.js", + "types": "./dist/interfaces/session-expiry-config.d.ts" }, "./interfaces/routes-config": { - "import": "./interfaces/routes-config.js", - "types": "./interfaces/routes-config.d.ts" + "import": "./dist/interfaces/routes-config.js", + "types": "./dist/interfaces/routes-config.d.ts" }, "./interfaces/sso-config": { - "import": "./interfaces/sso-config.js", - "types": "./interfaces/sso-config.d.ts" + "import": "./dist/interfaces/sso-config.js", + "types": "./dist/interfaces/sso-config.d.ts" }, "./interfaces/session-config": { - "import": "./interfaces/session-config.js", - "types": "./interfaces/session-config.d.ts" + "import": "./dist/interfaces/session-config.js", + "types": "./dist/interfaces/session-config.d.ts" }, "./interfaces/session-storage-config": { - "import": "./interfaces/session-storage-config.js", - "types": "./interfaces/session-storage-config.d.ts" + "import": "./dist/interfaces/session-storage-config.js", + "types": "./dist/interfaces/session-storage-config.d.ts" }, "./session": { - "import": "./session/index.js", - "types": "./session/index.d.ts" + "import": "./dist/session/index.js", + "types": "./dist/session/index.d.ts" }, "./session/session-storage": { - "import": "./session/session-storage/index.js", - "types": "./session/session-storage/index.d.ts" + "import": "./dist/session/session-storage/index.js", + "types": "./dist/session/session-storage/index.d.ts" }, "./session/session-expiry": { - "import": "./session/session-expiry/index.js", - "types": "./session/session-expiry/index.d.ts" - }, - "./session/session-user-state": { - "import": "./session/session-user-state/index.js", - "types": "./session/session-user-state/index.d.ts" + "import": "./dist/session/session-expiry/index.js", + "types": "./dist/session/session-expiry/index.d.ts" }, "./proxy": { - "import": "./proxy/index.js", - "types": "./proxy/index.d.ts" + "import": "./dist/proxy/index.js", + "types": "./dist/proxy/index.d.ts" }, "./proxy/opal-api-proxy": { - "import": "./proxy/opal-api-proxy/index.js", - "types": "./proxy/opal-api-proxy/index.d.ts" + "import": "./dist/proxy/opal-api-proxy/index.js", + "types": "./dist/proxy/opal-api-proxy/index.d.ts" }, "./types": { - "types": "./global.d.ts" + "types": "./dist/global.d.ts" } } } diff --git a/src/app-insights/index.ts b/src/app-insights/index.ts index 454ac4f..9c770c4 100644 --- a/src/app-insights/index.ts +++ b/src/app-insights/index.ts @@ -1,6 +1,6 @@ process.env['APPLICATIONINSIGHTS_CONFIGURATION_CONTENT'] = '{}'; import * as appInsights from 'applicationinsights'; -import AppInsightConfig from '../interfaces/app-insights-config'; +import AppInsightConfig from '../interfaces/app-insights-config.js'; // As of 2.9.0 issue reading bundled applicationinsights.json // https://github.com/microsoft/ApplicationInsights-node.js/issues/1226 diff --git a/src/index.ts b/src/index.ts index 1284eb6..3bdfc96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,11 @@ -export * from './launch-darkly'; -export * from './app-insights'; -export * from './health'; -export * from './helmet'; -export * from './properties-volume'; -export * from './csrf-token'; -export * from './routes'; -export * from './services'; +export * from './launch-darkly/index.js'; +export * from './app-insights/index.js'; +export * from './health/index.js'; +export * from './helmet/index.js'; +export * from './properties-volume/index.js'; +export * from './csrf-token/index.js'; +export * from './routes/index.js'; +export * from './services/index.js'; // INTERFACES -export * from './interfaces'; +export * from './interfaces/index.js'; diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index c60fe77..25dc1de 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -1,10 +1,10 @@ -export { default as SecurityToken } from './securityToken'; -export { default as launchDarklyConfig } from './launch-darkly-config'; -export { default as appInsightsConfig } from './app-insights-config'; -export { default as TransferServerState } from './transfer-server-state'; -export { default as ExpiryConfiguration } from './session-expiry-config'; -export { default as SessionStorageConfiguration } from './session-storage-config'; -export { default as RoutesConfiguration } from './routes-config'; -export { default as SsoConfiguration } from './sso-config'; -export { default as SessionConfiguration } from './session-config'; -export { default as OpalUserServiceConfiguration } from './opal-user-service-config'; +export { default as SecurityToken } from './securityToken.js'; +export { default as launchDarklyConfig } from './launch-darkly-config.js'; +export { default as appInsightsConfig } from './app-insights-config.js'; +export { default as TransferServerState } from './transfer-server-state.js'; +export { default as ExpiryConfiguration } from './session-expiry-config.js'; +export { default as SessionStorageConfiguration } from './session-storage-config.js'; +export { default as RoutesConfiguration } from './routes-config.js'; +export { default as SsoConfiguration } from './sso-config.js'; +export { default as SessionConfiguration } from './session-config.js'; +export { default as OpalUserServiceConfiguration } from './opal-user-service-config.js'; diff --git a/src/interfaces/transfer-server-state.ts b/src/interfaces/transfer-server-state.ts index eeeebe7..e2dae27 100644 --- a/src/interfaces/transfer-server-state.ts +++ b/src/interfaces/transfer-server-state.ts @@ -1,5 +1,5 @@ -import AppInsightsConfig from './app-insights-config'; -import LaunchDarklyConfig from './launch-darkly-config'; +import AppInsightsConfig from './app-insights-config.js'; +import LaunchDarklyConfig from './launch-darkly-config.js'; class TransferServerState { launchDarklyConfig!: LaunchDarklyConfig; diff --git a/src/launch-darkly/index.ts b/src/launch-darkly/index.ts index 2ac91ae..2641508 100644 --- a/src/launch-darkly/index.ts +++ b/src/launch-darkly/index.ts @@ -1,4 +1,4 @@ -import LaunchDarklyConfig from '../interfaces/launch-darkly-config'; +import LaunchDarklyConfig from '../interfaces/launch-darkly-config.js'; export class LaunchDarkly { public enableFor(enabled: boolean, stream: boolean, clientId: string | null): LaunchDarklyConfig { diff --git a/src/proxy/index.ts b/src/proxy/index.ts index aaa2b4e..bd38b57 100644 --- a/src/proxy/index.ts +++ b/src/proxy/index.ts @@ -1 +1 @@ -export * from './opal-api-proxy'; +export * from './opal-api-proxy/index.js'; diff --git a/src/routes/index.ts b/src/routes/index.ts index f9a7683..0ac7b34 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,17 +1,17 @@ import { Application } from 'express'; import bodyParser from 'body-parser'; import type { NextFunction, Request, Response } from 'express'; -import { ssoAuthenticated, ssoLogin, ssoLoginCallback } from '../sso'; -import createMsalInstance from '../sso/sso-configuration'; -import ssoLogout from '../sso/sso-logout'; -import { ssoAuthenticatedStub, ssoLogoutCallbackStub, ssoLoginStub, ssoLoginCallbackStub } from '../stubs/sso'; -import sessionExpiry from '@hmcts/opal-frontend-common-node/session/session-expiry'; -import ExpiryConfiguration from '@hmcts/opal-frontend-common-node/interfaces/session-expiry-config'; -import RoutesConfiguration from '@hmcts/opal-frontend-common-node/interfaces/routes-config'; -import SsoConfiguration from '@hmcts/opal-frontend-common-node/interfaces/sso-config'; -import SessionConfiguration from '@hmcts/opal-frontend-common-node/interfaces/session-config'; -import ssoLogoutCallback from '../sso/sso-logout-callback'; -import OpalUserServiceConfig from '../interfaces/opal-user-service-config'; +import { ssoAuthenticated, ssoLogin, ssoLoginCallback } from '../sso/index.js'; +import createMsalInstance from '../sso/sso-configuration.js'; +import ssoLogout from '../sso/sso-logout.js'; +import { ssoAuthenticatedStub, ssoLogoutCallbackStub, ssoLoginStub, ssoLoginCallbackStub } from '../stubs/sso/index.js'; +import sessionExpiry from '../session/session-expiry/index.js'; +import ExpiryConfiguration from '../interfaces/session-expiry-config.js'; +import RoutesConfiguration from '../interfaces/routes-config.js'; +import SsoConfiguration from '../interfaces/sso-config.js'; +import SessionConfiguration from '../interfaces/session-config.js'; +import ssoLogoutCallback from '../sso/sso-logout-callback.js'; +import OpalUserServiceConfig from '../interfaces/opal-user-service-config.js'; export class Routes { private setupSSORoutes( diff --git a/src/services/index.ts b/src/services/index.ts index 37a1ae4..fcb9c80 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1 +1 @@ -export { handleCheckUser } from './opal-user-service'; +export { handleCheckUser } from './opal-user-service.js'; diff --git a/src/services/opal-user-service.ts b/src/services/opal-user-service.ts index 9d52158..e72b2e3 100644 --- a/src/services/opal-user-service.ts +++ b/src/services/opal-user-service.ts @@ -1,6 +1,6 @@ import axios from 'axios'; import { Logger } from '@hmcts/nodejs-logging'; -import OpalUserServiceConfiguration from '../interfaces/opal-user-service-config'; +import OpalUserServiceConfiguration from '../interfaces/opal-user-service-config.js'; const logger = Logger.getLogger('opal-user-service'); diff --git a/src/session.d.ts b/src/session.d.ts index 893d3c8..6186a27 100644 --- a/src/session.d.ts +++ b/src/session.d.ts @@ -1,4 +1,4 @@ -import { SecurityToken } from './interfaces/index'; +import { SecurityToken } from './interfaces/index.js'; declare module 'express-session' { interface SessionData { diff --git a/src/session/index.ts b/src/session/index.ts index f76e438..ceee3d0 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -1,2 +1,2 @@ -export { default as SessionStorage } from './session-storage'; -export { default as sessionExpiry } from './session-expiry'; +export { default as SessionStorage } from './session-storage/index.js'; +export { default as sessionExpiry } from './session-expiry/index.js'; diff --git a/src/session/session-expiry/index.ts b/src/session/session-expiry/index.ts index a58c012..028752a 100644 --- a/src/session/session-expiry/index.ts +++ b/src/session/session-expiry/index.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import { DateTime } from 'luxon'; -import { Jwt } from '../../utils'; +import { Jwt } from '../../utils/index.js'; const sessionExpiry = ( req: Request, diff --git a/src/sso/index.ts b/src/sso/index.ts index 8ea7eb0..e533101 100644 --- a/src/sso/index.ts +++ b/src/sso/index.ts @@ -1,5 +1,5 @@ -export { default as ssoLogin } from './sso-login'; -export { default as ssoLogout } from './sso-logout'; -export { default as ssoLoginCallback } from './sso-login-callback'; -export { default as ssoAuthenticated } from './sso-authenticated'; -export { default as ssoConfig } from './sso-configuration'; +export { default as ssoLogin } from './sso-login.js'; +export { default as ssoLogout } from './sso-logout.js'; +export { default as ssoLoginCallback } from './sso-login-callback.js'; +export { default as ssoAuthenticated } from './sso-authenticated.js'; +export { default as ssoConfig } from './sso-configuration.js'; diff --git a/src/sso/sso-authenticated.ts b/src/sso/sso-authenticated.ts index 7e89ccc..674d11f 100644 --- a/src/sso/sso-authenticated.ts +++ b/src/sso/sso-authenticated.ts @@ -1,5 +1,5 @@ import { Request, Response } from 'express'; -import { Jwt } from '../utils'; +import { Jwt } from '../utils/index.js'; /** * Express handler that verifies whether the current session has a valid (non-expired) access token. diff --git a/src/sso/sso-login-callback.ts b/src/sso/sso-login-callback.ts index b885db4..dfb8707 100644 --- a/src/sso/sso-login-callback.ts +++ b/src/sso/sso-login-callback.ts @@ -1,10 +1,10 @@ import { Request, Response } from 'express'; import { ConfidentialClientApplication } from '@azure/msal-node'; import 'express-session'; -import { RoutesConfiguration, SecurityToken } from '../interfaces'; +import { RoutesConfiguration, SecurityToken } from '../interfaces/index.js'; import { Logger } from '@hmcts/nodejs-logging'; -import { handleCheckUser } from '../services/opal-user-service'; -import OpalUserServiceConfiguration from '../interfaces/opal-user-service-config'; +import { handleCheckUser } from '../services/opal-user-service.js'; +import OpalUserServiceConfiguration from '../interfaces/opal-user-service-config.js'; const logger = Logger.getLogger('sso-login-callback'); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); diff --git a/src/stubs/sso/index.ts b/src/stubs/sso/index.ts index 66f5424..b1e1937 100644 --- a/src/stubs/sso/index.ts +++ b/src/stubs/sso/index.ts @@ -1,5 +1,5 @@ -export { default as ssoLoginStub } from './sso-login.stub'; -export { default as ssoLoginCallbackStub } from './sso-login-callback.stub'; -export { default as ssoAuthenticatedStub } from './sso-authenticated.stub'; -export { default as ssoLogoutStub } from './sso-logout.stub'; -export { default as ssoLogoutCallbackStub } from './sso-logout-callback.stub'; +export { default as ssoLoginStub } from './sso-login.stub.js'; +export { default as ssoLoginCallbackStub } from './sso-login-callback.stub.js'; +export { default as ssoAuthenticatedStub } from './sso-authenticated.stub.js'; +export { default as ssoLogoutStub } from './sso-logout.stub.js'; +export { default as ssoLogoutCallbackStub } from './sso-logout-callback.stub.js'; diff --git a/src/stubs/sso/sso-authenticated.stub.ts b/src/stubs/sso/sso-authenticated.stub.ts index 61625fd..4da9153 100644 --- a/src/stubs/sso/sso-authenticated.stub.ts +++ b/src/stubs/sso/sso-authenticated.stub.ts @@ -1,5 +1,5 @@ import { Request, Response } from 'express'; -import { Jwt } from '../../utils'; +import { Jwt } from '../../utils/index.js'; /** * Middleware stub that validates whether the current request is authenticated via a JWT stored on the session. diff --git a/src/stubs/sso/sso-logout.stub.ts b/src/stubs/sso/sso-logout.stub.ts index 8c52bbc..fdc4fff 100644 --- a/src/stubs/sso/sso-logout.stub.ts +++ b/src/stubs/sso/sso-logout.stub.ts @@ -1,5 +1,5 @@ import { Request, Response } from 'express'; -import { Jwt } from '../../utils'; +import { Jwt } from '../../utils/index.js'; /** * Handles the SSO logout stub endpoint. diff --git a/src/utils/index.ts b/src/utils/index.ts index 026bd44..5b97987 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1 @@ -export { Jwt } from './jwt'; +export { Jwt } from './jwt.js'; diff --git a/tsconfig.json b/tsconfig.json index 902d11e..cb79655 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,12 @@ { "compileOnSave": false, "compilerOptions": { + "rootDir": "./src", "outDir": "./dist", "strict": true, "noImplicitOverride": true, "target": "ES2022", - "module": "ES2022", + "module": "NodeNext", "lib": ["ES2022", "dom"], "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, @@ -17,7 +18,7 @@ "declaration": true, "declarationMap": true, "emitDeclarationOnly": false, - "moduleResolution": "node", + "moduleResolution": "NodeNext", "typeRoots": ["node_modules/@types"], "paths": { "@hmcts/opal-frontend-common-node": [ @@ -59,9 +60,6 @@ "@hmcts/opal-frontend-common-node/session/session-expiry/*": [ "./src/session/session-expiry/*", ], - "@hmcts/opal-frontend-common-node/session/session-user-state/*": [ - "./src/session/session-user-state/*", - ], "@hmcts/opal-frontend-common-node/session/session-storage/*": [ "./src/session/session-storage/*", ] From 68dfe5cfcb9f1ff9af42d3da9ab5cc944a39e277 Mon Sep 17 00:00:00 2001 From: Frankie Moran Date: Mon, 23 Feb 2026 11:26:02 +0000 Subject: [PATCH 02/11] Phase 2: safety layer --- .github/workflows/npm_build.yml | 30 ++++++++++++++++++++++++++++++ README.md | 1 + 2 files changed, 31 insertions(+) diff --git a/.github/workflows/npm_build.yml b/.github/workflows/npm_build.yml index 4c7dc78..8d24109 100644 --- a/.github/workflows/npm_build.yml +++ b/.github/workflows/npm_build.yml @@ -39,6 +39,21 @@ jobs: - name: Build library run: yarn build + - name: Smoke test package exports (ESM) + run: | + node --input-type=module - <<'NODE' + import { readFileSync } from 'node:fs'; + + const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')); + const runtimeSpecifiers = Object.entries(pkg.exports) + .filter(([, exportConditions]) => typeof exportConditions === 'object' && exportConditions.import) + .map(([subpath]) => (subpath === '.' ? pkg.name : `${pkg.name}/${subpath.slice(2)}`)); + + for (const specifier of runtimeSpecifiers) { + await import(specifier); + } + NODE + - name: Analyze with SonarCloud uses: SonarSource/sonarqube-scan-action@v7.0.0 env: @@ -83,6 +98,21 @@ jobs: - name: Build library (Release) run: yarn build + - name: Smoke test package exports (ESM) (Release) + run: | + node --input-type=module - <<'NODE' + import { readFileSync } from 'node:fs'; + + const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')); + const runtimeSpecifiers = Object.entries(pkg.exports) + .filter(([, exportConditions]) => typeof exportConditions === 'object' && exportConditions.import) + .map(([subpath]) => (subpath === '.' ? pkg.name : `${pkg.name}/${subpath.slice(2)}`)); + + for (const specifier of runtimeSpecifiers) { + await import(specifier); + } + NODE + - name: Publish version (OIDC via npm) run: | # Ensure we do NOT publish using token-based auth (classic/granular). diff --git a/README.md b/README.md index 321cad6..971eae5 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Ensure you have the following installed: - [Node.js](https://nodejs.org/) v18 or later - [Yarn](https://classic.yarnpkg.com/) v1.22.22 or later +- ESM-compatible consumer setup (`import` syntax). CommonJS `require()` is not supported. ### Install Dependencies From 31ae276620d1d9a12eafa1b84505cf12b41b1719 Mon Sep 17 00:00:00 2001 From: Frankie Moran Date: Mon, 23 Feb 2026 11:29:45 +0000 Subject: [PATCH 03/11] Phase 3 --- .github/workflows/npm_build.yml | 144 ++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/.github/workflows/npm_build.yml b/.github/workflows/npm_build.yml index 8d24109..7aaaf55 100644 --- a/.github/workflows/npm_build.yml +++ b/.github/workflows/npm_build.yml @@ -39,6 +39,78 @@ jobs: - name: Build library run: yarn build + - name: Validate export targets exist + run: | + node - <<'NODE' + const fs = require('node:fs'); + const path = require('node:path'); + + const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf-8')); + const failures = []; + + for (const [subpath, exportDefinition] of Object.entries(pkg.exports || {})) { + if (typeof exportDefinition === 'string') { + const resolved = path.resolve(exportDefinition.replace(/^\.\//, '')); + if (!fs.existsSync(resolved)) { + failures.push(`${subpath} -> ${exportDefinition}`); + } + continue; + } + + for (const [condition, target] of Object.entries(exportDefinition)) { + if (typeof target !== 'string') { + continue; + } + const resolved = path.resolve(target.replace(/^\.\//, '')); + if (!fs.existsSync(resolved)) { + failures.push(`${subpath} (${condition}) -> ${target}`); + } + } + } + + if (failures.length) { + console.error('Missing export targets:'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); + } + NODE + + - name: Validate npm pack publish shape + run: | + npm_config_cache="$RUNNER_TEMP/npm-cache" npm pack --dry-run --json > pack-output.json + node - <<'NODE' + const fs = require('node:fs'); + + const [{ files }] = JSON.parse(fs.readFileSync('./pack-output.json', 'utf-8')); + const required = ['package.json', 'README.md', 'LICENSE', 'dist/index.js', 'dist/index.d.ts']; + const allowedRootFiles = new Set(['package.json', 'README.md', 'LICENSE']); + + const fileSet = new Set(files.map((entry) => entry.path)); + const missingRequired = required.filter((file) => !fileSet.has(file)); + const unexpected = files + .map((entry) => entry.path) + .filter((file) => !file.startsWith('dist/') && !allowedRootFiles.has(file)); + + if (missingRequired.length || unexpected.length) { + if (missingRequired.length) { + console.error('Missing required packed files:'); + for (const file of missingRequired) { + console.error(`- ${file}`); + } + } + if (unexpected.length) { + console.error('Unexpected packed files:'); + for (const file of unexpected) { + console.error(`- ${file}`); + } + } + process.exit(1); + } + NODE + rm -f pack-output.json + - name: Smoke test package exports (ESM) run: | node --input-type=module - <<'NODE' @@ -98,6 +170,78 @@ jobs: - name: Build library (Release) run: yarn build + - name: Validate export targets exist (Release) + run: | + node - <<'NODE' + const fs = require('node:fs'); + const path = require('node:path'); + + const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf-8')); + const failures = []; + + for (const [subpath, exportDefinition] of Object.entries(pkg.exports || {})) { + if (typeof exportDefinition === 'string') { + const resolved = path.resolve(exportDefinition.replace(/^\.\//, '')); + if (!fs.existsSync(resolved)) { + failures.push(`${subpath} -> ${exportDefinition}`); + } + continue; + } + + for (const [condition, target] of Object.entries(exportDefinition)) { + if (typeof target !== 'string') { + continue; + } + const resolved = path.resolve(target.replace(/^\.\//, '')); + if (!fs.existsSync(resolved)) { + failures.push(`${subpath} (${condition}) -> ${target}`); + } + } + } + + if (failures.length) { + console.error('Missing export targets:'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); + } + NODE + + - name: Validate npm pack publish shape (Release) + run: | + npm_config_cache="$RUNNER_TEMP/npm-cache" npm pack --dry-run --json > pack-output.json + node - <<'NODE' + const fs = require('node:fs'); + + const [{ files }] = JSON.parse(fs.readFileSync('./pack-output.json', 'utf-8')); + const required = ['package.json', 'README.md', 'LICENSE', 'dist/index.js', 'dist/index.d.ts']; + const allowedRootFiles = new Set(['package.json', 'README.md', 'LICENSE']); + + const fileSet = new Set(files.map((entry) => entry.path)); + const missingRequired = required.filter((file) => !fileSet.has(file)); + const unexpected = files + .map((entry) => entry.path) + .filter((file) => !file.startsWith('dist/') && !allowedRootFiles.has(file)); + + if (missingRequired.length || unexpected.length) { + if (missingRequired.length) { + console.error('Missing required packed files:'); + for (const file of missingRequired) { + console.error(`- ${file}`); + } + } + if (unexpected.length) { + console.error('Unexpected packed files:'); + for (const file of unexpected) { + console.error(`- ${file}`); + } + } + process.exit(1); + } + NODE + rm -f pack-output.json + - name: Smoke test package exports (ESM) (Release) run: | node --input-type=module - <<'NODE' From 38ddbc1a3fd86e5d5156a9c9c751b7f740975d5b Mon Sep 17 00:00:00 2001 From: Frankie Moran Date: Mon, 23 Feb 2026 11:32:00 +0000 Subject: [PATCH 04/11] Phase 4 --- .github/workflows/npm_build.yml | 17 +++++++++++++++++ CHANGELOG.md | 17 +++++++++++++++++ README.md | 17 +++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 CHANGELOG.md diff --git a/.github/workflows/npm_build.yml b/.github/workflows/npm_build.yml index 7aaaf55..e3b4c82 100644 --- a/.github/workflows/npm_build.yml +++ b/.github/workflows/npm_build.yml @@ -148,6 +148,23 @@ jobs: id: release uses: manovotny/github-releases-for-automated-package-publishing-action@v2.0.1 + - name: Validate release tag matches package version + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + node - <<'NODE' + const fs = require('node:fs'); + + const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf-8')); + const tag = process.env.RELEASE_TAG || ''; + const normalizedTag = tag.startsWith('v') ? tag.slice(1) : tag; + + if (normalizedTag !== pkg.version) { + console.error(`Release tag (${tag}) does not match package.json version (${pkg.version}).`); + process.exit(1); + } + NODE + - name: Setup Node.js (Release) uses: actions/setup-node@v6 with: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6afce5f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to this package should be documented in this file. + +The format is based on Keep a Changelog and this project follows semantic versioning. + +## [Unreleased] + +### Changed +- _Add entries here for each PR that changes public behavior, exports, or consumer configuration._ + +## Changelog Policy + +- Add entries to `## [Unreleased]` for user-visible changes. +- Move entries from `## [Unreleased]` into a versioned section during release. +- Call out breaking changes explicitly with migration notes. +- Mention any `package.json` `exports` changes that affect consumers. diff --git a/README.md b/README.md index 971eae5..4c64bb2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This is a shared Node.js library containing common middleware, configurations, a - [Getting Started](#getting-started) - [Scripts](#scripts) - [Build Process](#build-process) +- [Release Checklist](#release-checklist) - [Linting and Formatting](#linting-and-formatting) - [Exports](#exports) - [Using This Library in a Node.js Application](#using-this-library-in-a-nodejs-application) @@ -100,6 +101,22 @@ After this new version of the library is published, any consuming application sh yarn import:published:common-node-lib ``` +## Release Checklist + +Before creating a GitHub release tag: + +1. Update `package.json` version to the intended release version. +2. Add a `CHANGELOG.md` entry under `## [Unreleased]` describing user-visible changes. +3. Run: + + ```bash + yarn build + npm pack --dry-run + ``` + +4. Ensure export map changes are intentional and non-breaking (or include release notes/version bump for breaking changes). +5. Create a GitHub release with a tag matching `package.json` version (`vX.Y.Z` or `X.Y.Z`). + ## Linting and Formatting To lint and check formatting: From d6ede9afea994a7af0bce81356bccf041598493f Mon Sep 17 00:00:00 2001 From: Frankie Moran Date: Mon, 23 Feb 2026 11:57:58 +0000 Subject: [PATCH 05/11] feat(types): add typesVersions for improved TypeScript support --- package.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/package.json b/package.json index d0320c6..386a6a3 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,17 @@ }, "main": "./dist/index.js", "types": "./dist/index.d.ts", + "typesVersions": { + "*": { + "types": [ + "dist/global.d.ts" + ], + "*": [ + "dist/*/index.d.ts", + "dist/*.d.ts" + ] + } + }, "files": [ "dist/**", "README.md", From 76908e2f0fb0f491ee0d100e7cc1ec6b301a92e3 Mon Sep 17 00:00:00 2001 From: Frankie Moran Date: Mon, 23 Feb 2026 12:07:20 +0000 Subject: [PATCH 06/11] (bump): version number --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 386a6a3..fd06539 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hmcts/opal-frontend-common-node", "type": "module", - "version": "0.0.23", + "version": "0.0.24", "license": "MIT", "description": "Common nodejs library components for opal", "engines": { From 3dbe4c3045b47accbf4283d51334a4775be92b14 Mon Sep 17 00:00:00 2001 From: Frankie Moran Date: Mon, 23 Feb 2026 16:19:26 +0000 Subject: [PATCH 07/11] bump version number --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fd06539..1969b14 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hmcts/opal-frontend-common-node", "type": "module", - "version": "0.0.24", + "version": "0.0.25", "license": "MIT", "description": "Common nodejs library components for opal", "engines": { From edb864e461501f6bc41f021ff4e240dbfdb2cf00 Mon Sep 17 00:00:00 2001 From: Frankie Moran Date: Tue, 24 Feb 2026 09:01:54 +0000 Subject: [PATCH 08/11] Update docs and add .tgz to gitignore --- .gitignore | 5 ++++- README.md | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 28b20d7..d3663eb 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,7 @@ multi-reporter-config.json # Yarn audit working files yarn-known-issues-current current-ids.json -known-ids.json \ No newline at end of file +known-ids.json + +# npm pack artifacts +*.tgz \ No newline at end of file diff --git a/README.md b/README.md index 4c64bb2..6677584 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ This is a shared Node.js library containing common middleware, configurations, a Ensure you have the following installed: - [Node.js](https://nodejs.org/) v18 or later -- [Yarn](https://classic.yarnpkg.com/) v1.22.22 or later +- [Yarn](https://yarnpkg.com/) v4.x (Berry) - ESM-compatible consumer setup (`import` syntax). CommonJS `require()` is not supported. ### Install Dependencies @@ -41,7 +41,7 @@ Run the following to build the project: yarn build ``` -The compiled output will be available in the `dist/` folder. It includes runtime JS and type declarations for the package exports. +The compiled output will be available in the `dist/` folder. It includes runtime JavaScript and generated type declarations based on the TypeScript build configuration. ## Switching Between Local and Published Versions @@ -58,12 +58,14 @@ To use a published version of this library during development in another project To use a local version of this library during development in another project: -1. Build this library: +1. Build and pack this library: ```bash - yarn build + yarn pack:local ``` + This will generate a `.tgz` file (e.g. `hmcts-opal-frontend-common-node-X.Y.Z.tgz`) in the repository root. + 2. In your consuming project (e.g. `opal-frontend`), ensure you have set an environment variable pointing to this library repository root (not `dist/`): ```bash @@ -77,9 +79,10 @@ To use a local version of this library during development in another project: yarn import:local:common-node-lib ``` - This will remove the published version and install the local package using the path provided. + This will remove the currently installed version and install the locally packed `.tgz` artifact. Installing the tarball (rather than linking the repository folder directly) ensures the consuming project uses the same publish shape as npm, avoiding TypeScript and export-map resolution issues. 4. To switch back to the published version: + ```bash yarn import:published:common-node-lib ``` @@ -175,7 +178,10 @@ Refer to the `exports` block in `package.json` for the full list of available mo The following commands are available in the `package.json`: - `yarn build` - Cleans the `dist/` folder, compiles TypeScript, and copies relevant files to `dist/`. + Cleans the `dist/` folder and compiles TypeScript into the publishable `dist/` output. + +- `yarn pack:local` + Builds the project (via the `prepack` hook) and creates a local `.tgz` package that mirrors the published npm artifact. Useful for testing changes in a consuming application. - `yarn clean` Removes the `dist/` directory. From 8df008baa3917af4a443da9d1cdf69f308cbdd0c Mon Sep 17 00:00:00 2001 From: Frankie Moran Date: Tue, 24 Feb 2026 09:35:48 +0000 Subject: [PATCH 09/11] Refactor CI pipeline and scripts for improved export validation and packaging checks --- .github/workflows/npm_build.yml | 207 ++++-------------- README.md | 2 +- package.json | 64 +++++- scripts/smoke-test-exports.mjs | 12 + scripts/validate-export-targets.cjs | 36 +++ scripts/validate-pack-shape.cjs | 77 +++++++ .../SKILL.md | 10 +- .../SKILL.md | 2 + 8 files changed, 236 insertions(+), 174 deletions(-) create mode 100644 scripts/smoke-test-exports.mjs create mode 100644 scripts/validate-export-targets.cjs create mode 100644 scripts/validate-pack-shape.cjs diff --git a/.github/workflows/npm_build.yml b/.github/workflows/npm_build.yml index e3b4c82..585d472 100644 --- a/.github/workflows/npm_build.yml +++ b/.github/workflows/npm_build.yml @@ -40,97 +40,52 @@ jobs: run: yarn build - name: Validate export targets exist - run: | - node - <<'NODE' - const fs = require('node:fs'); - const path = require('node:path'); - - const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf-8')); - const failures = []; - - for (const [subpath, exportDefinition] of Object.entries(pkg.exports || {})) { - if (typeof exportDefinition === 'string') { - const resolved = path.resolve(exportDefinition.replace(/^\.\//, '')); - if (!fs.existsSync(resolved)) { - failures.push(`${subpath} -> ${exportDefinition}`); - } - continue; - } - - for (const [condition, target] of Object.entries(exportDefinition)) { - if (typeof target !== 'string') { - continue; - } - const resolved = path.resolve(target.replace(/^\.\//, '')); - if (!fs.existsSync(resolved)) { - failures.push(`${subpath} (${condition}) -> ${target}`); - } - } - } - - if (failures.length) { - console.error('Missing export targets:'); - for (const failure of failures) { - console.error(`- ${failure}`); - } - process.exit(1); - } - NODE + run: yarn check:exports - name: Validate npm pack publish shape - run: | - npm_config_cache="$RUNNER_TEMP/npm-cache" npm pack --dry-run --json > pack-output.json - node - <<'NODE' - const fs = require('node:fs'); - - const [{ files }] = JSON.parse(fs.readFileSync('./pack-output.json', 'utf-8')); - const required = ['package.json', 'README.md', 'LICENSE', 'dist/index.js', 'dist/index.d.ts']; - const allowedRootFiles = new Set(['package.json', 'README.md', 'LICENSE']); - - const fileSet = new Set(files.map((entry) => entry.path)); - const missingRequired = required.filter((file) => !fileSet.has(file)); - const unexpected = files - .map((entry) => entry.path) - .filter((file) => !file.startsWith('dist/') && !allowedRootFiles.has(file)); - - if (missingRequired.length || unexpected.length) { - if (missingRequired.length) { - console.error('Missing required packed files:'); - for (const file of missingRequired) { - console.error(`- ${file}`); - } - } - if (unexpected.length) { - console.error('Unexpected packed files:'); - for (const file of unexpected) { - console.error(`- ${file}`); - } - } - process.exit(1); - } - NODE - rm -f pack-output.json + run: yarn check:pack-shape - name: Smoke test package exports (ESM) - run: | - node --input-type=module - <<'NODE' - import { readFileSync } from 'node:fs'; - - const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')); - const runtimeSpecifiers = Object.entries(pkg.exports) - .filter(([, exportConditions]) => typeof exportConditions === 'object' && exportConditions.import) - .map(([subpath]) => (subpath === '.' ? pkg.name : `${pkg.name}/${subpath.slice(2)}`)); - - for (const specifier of runtimeSpecifiers) { - await import(specifier); - } - NODE + run: yarn check:exports:esm - name: Analyze with SonarCloud uses: SonarSource/sonarqube-scan-action@v7.0.0 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + node18-compat: + name: Node 18 Compatibility + if: github.event_name != 'release' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node.js (18) + uses: actions/setup-node@v6 + with: + node-version: '18.x' + + - name: Enable Corepack + run: | + corepack enable + corepack prepare yarn@4.12.0 --activate + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Build library + run: yarn build + + - name: Validate export targets exist + run: yarn check:exports + + - name: Validate npm pack publish shape + run: yarn check:pack-shape + + - name: Smoke test package exports (ESM) + run: yarn check:exports:esm + release: name: Release and Publish if: github.event_name == 'release' @@ -142,11 +97,7 @@ jobs: - name: Checkout repository (Release) uses: actions/checkout@v6 with: - ref: ${{ github.event.release.target_commitish }} - - - name: Validate and extract release information - id: release - uses: manovotny/github-releases-for-automated-package-publishing-action@v2.0.1 + ref: refs/tags/${{ github.event.release.tag_name }} - name: Validate release tag matches package version env: @@ -188,91 +139,13 @@ jobs: run: yarn build - name: Validate export targets exist (Release) - run: | - node - <<'NODE' - const fs = require('node:fs'); - const path = require('node:path'); - - const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf-8')); - const failures = []; - - for (const [subpath, exportDefinition] of Object.entries(pkg.exports || {})) { - if (typeof exportDefinition === 'string') { - const resolved = path.resolve(exportDefinition.replace(/^\.\//, '')); - if (!fs.existsSync(resolved)) { - failures.push(`${subpath} -> ${exportDefinition}`); - } - continue; - } - - for (const [condition, target] of Object.entries(exportDefinition)) { - if (typeof target !== 'string') { - continue; - } - const resolved = path.resolve(target.replace(/^\.\//, '')); - if (!fs.existsSync(resolved)) { - failures.push(`${subpath} (${condition}) -> ${target}`); - } - } - } - - if (failures.length) { - console.error('Missing export targets:'); - for (const failure of failures) { - console.error(`- ${failure}`); - } - process.exit(1); - } - NODE + run: yarn check:exports - name: Validate npm pack publish shape (Release) - run: | - npm_config_cache="$RUNNER_TEMP/npm-cache" npm pack --dry-run --json > pack-output.json - node - <<'NODE' - const fs = require('node:fs'); - - const [{ files }] = JSON.parse(fs.readFileSync('./pack-output.json', 'utf-8')); - const required = ['package.json', 'README.md', 'LICENSE', 'dist/index.js', 'dist/index.d.ts']; - const allowedRootFiles = new Set(['package.json', 'README.md', 'LICENSE']); - - const fileSet = new Set(files.map((entry) => entry.path)); - const missingRequired = required.filter((file) => !fileSet.has(file)); - const unexpected = files - .map((entry) => entry.path) - .filter((file) => !file.startsWith('dist/') && !allowedRootFiles.has(file)); - - if (missingRequired.length || unexpected.length) { - if (missingRequired.length) { - console.error('Missing required packed files:'); - for (const file of missingRequired) { - console.error(`- ${file}`); - } - } - if (unexpected.length) { - console.error('Unexpected packed files:'); - for (const file of unexpected) { - console.error(`- ${file}`); - } - } - process.exit(1); - } - NODE - rm -f pack-output.json + run: yarn check:pack-shape - name: Smoke test package exports (ESM) (Release) - run: | - node --input-type=module - <<'NODE' - import { readFileSync } from 'node:fs'; - - const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')); - const runtimeSpecifiers = Object.entries(pkg.exports) - .filter(([, exportConditions]) => typeof exportConditions === 'object' && exportConditions.import) - .map(([subpath]) => (subpath === '.' ? pkg.name : `${pkg.name}/${subpath.slice(2)}`)); - - for (const specifier of runtimeSpecifiers) { - await import(specifier); - } - NODE + run: yarn check:exports:esm - name: Publish version (OIDC via npm) run: | diff --git a/README.md b/README.md index 6677584..017535b 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ The following commands are available in the `package.json`: Cleans the `dist/` folder and compiles TypeScript into the publishable `dist/` output. - `yarn pack:local` - Builds the project (via the `prepack` hook) and creates a local `.tgz` package that mirrors the published npm artifact. Useful for testing changes in a consuming application. + Builds the project (via the `prepack` hook), removes old local tarballs, and creates a fresh `.tgz` package that mirrors the published npm artifact. Useful for testing changes in a consuming application. - `yarn clean` Removes the `dist/` directory. diff --git a/package.json b/package.json index 1969b14..e2e09f8 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,62 @@ "types": [ "dist/global.d.ts" ], - "*": [ - "dist/*/index.d.ts", - "dist/*.d.ts" + "app-insights": [ + "dist/app-insights/index.d.ts" + ], + "health": [ + "dist/health/index.d.ts" + ], + "helmet": [ + "dist/helmet/index.d.ts" + ], + "launch-darkly": [ + "dist/launch-darkly/index.d.ts" + ], + "properties-volume": [ + "dist/properties-volume/index.d.ts" + ], + "csrf-token": [ + "dist/csrf-token/index.d.ts" + ], + "routes": [ + "dist/routes/index.d.ts" + ], + "services": [ + "dist/services/index.d.ts" + ], + "interfaces": [ + "dist/interfaces/index.d.ts" + ], + "interfaces/session-expiry-config": [ + "dist/interfaces/session-expiry-config.d.ts" + ], + "interfaces/routes-config": [ + "dist/interfaces/routes-config.d.ts" + ], + "interfaces/sso-config": [ + "dist/interfaces/sso-config.d.ts" + ], + "interfaces/session-config": [ + "dist/interfaces/session-config.d.ts" + ], + "interfaces/session-storage-config": [ + "dist/interfaces/session-storage-config.d.ts" + ], + "session": [ + "dist/session/index.d.ts" + ], + "session/session-storage": [ + "dist/session/session-storage/index.d.ts" + ], + "session/session-expiry": [ + "dist/session/session-expiry/index.d.ts" + ], + "proxy": [ + "dist/proxy/index.d.ts" + ], + "proxy/opal-api-proxy": [ + "dist/proxy/opal-api-proxy/index.d.ts" ] } }, @@ -32,6 +85,11 @@ "sideEffects": false, "scripts": { "build": "yarn clean && tsc && cp src/*.d.ts dist/", + "prepack": "yarn build", + "pack:local": "find . -maxdepth 1 -type f -name 'hmcts-opal-frontend-common-node-*.tgz' -delete && npm_config_cache=${TMPDIR:-/tmp}/npm-cache-opal-common-node npm pack", + "check:exports": "node scripts/validate-export-targets.cjs", + "check:pack-shape": "node scripts/validate-pack-shape.cjs", + "check:exports:esm": "node scripts/smoke-test-exports.mjs", "clean": "rm -rf dist", "lint": "eslint ./src --ext .ts && yarn prettier", "prettier": "prettier --check \"./src/**/*.{ts,js,json}\"", diff --git a/scripts/smoke-test-exports.mjs b/scripts/smoke-test-exports.mjs new file mode 100644 index 0000000..b355e82 --- /dev/null +++ b/scripts/smoke-test-exports.mjs @@ -0,0 +1,12 @@ +import { readFileSync } from 'node:fs'; + +const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')); +const runtimeSpecifiers = Object.entries(pkg.exports || {}) + .filter(([, exportConditions]) => typeof exportConditions === 'object' && exportConditions.import) + .map(([subpath]) => (subpath === '.' ? pkg.name : `${pkg.name}/${subpath.slice(2)}`)); + +for (const specifier of runtimeSpecifiers) { + await import(specifier); +} + +console.log('Runtime export smoke test passed.'); diff --git a/scripts/validate-export-targets.cjs b/scripts/validate-export-targets.cjs new file mode 100644 index 0000000..ddbf808 --- /dev/null +++ b/scripts/validate-export-targets.cjs @@ -0,0 +1,36 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const pkg = JSON.parse(fs.readFileSync(path.resolve('package.json'), 'utf-8')); +const failures = []; + +for (const [subpath, exportDefinition] of Object.entries(pkg.exports || {})) { + if (typeof exportDefinition === 'string') { + const resolved = path.resolve(exportDefinition.replace(/^\.\//, '')); + if (!fs.existsSync(resolved)) { + failures.push(`${subpath} -> ${exportDefinition}`); + } + continue; + } + + for (const [condition, target] of Object.entries(exportDefinition)) { + if (typeof target !== 'string') { + continue; + } + + const resolved = path.resolve(target.replace(/^\.\//, '')); + if (!fs.existsSync(resolved)) { + failures.push(`${subpath} (${condition}) -> ${target}`); + } + } +} + +if (failures.length) { + console.error('Missing export targets:'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log('Export targets are valid.'); diff --git a/scripts/validate-pack-shape.cjs b/scripts/validate-pack-shape.cjs new file mode 100644 index 0000000..9ac6bf8 --- /dev/null +++ b/scripts/validate-pack-shape.cjs @@ -0,0 +1,77 @@ +const { spawnSync } = require('node:child_process'); +const os = require('node:os'); +const path = require('node:path'); + +const required = [ + 'package.json', + 'README.md', + 'LICENSE', + 'dist/index.js', + 'dist/index.d.ts', + 'dist/global.d.ts', + 'dist/session.d.ts', +]; +const allowedRootFiles = new Set(['package.json', 'README.md', 'LICENSE']); + +const env = { ...process.env }; +if (!env.npm_config_cache) { + const cacheRoot = env.RUNNER_TEMP || os.tmpdir(); + env.npm_config_cache = path.join(cacheRoot, 'npm-cache-opal-common-node'); +} + +const pack = spawnSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + env, + encoding: 'utf-8', +}); + +if (pack.status !== 0) { + process.stderr.write(pack.stdout || ''); + process.stderr.write(pack.stderr || ''); + process.exit(pack.status ?? 1); +} + +const output = (pack.stdout || '').trim(); +const firstBracket = output.indexOf('['); +const lastBracket = output.lastIndexOf(']'); +const jsonSlice = firstBracket >= 0 && lastBracket > firstBracket ? output.slice(firstBracket, lastBracket + 1) : output; + +let parsed; +try { + parsed = JSON.parse(jsonSlice); +} catch (error) { + console.error('Failed to parse `npm pack --dry-run --json` output.'); + console.error(jsonSlice); + throw error; +} + +const files = parsed?.[0]?.files; +if (!Array.isArray(files)) { + console.error('Unexpected npm pack output shape.'); + process.exit(1); +} + +const fileSet = new Set(files.map((entry) => entry.path)); +const missingRequired = required.filter((file) => !fileSet.has(file)); +const unexpected = files + .map((entry) => entry.path) + .filter((file) => !file.startsWith('dist/') && !allowedRootFiles.has(file)); + +if (missingRequired.length || unexpected.length) { + if (missingRequired.length) { + console.error('Missing required packed files:'); + for (const file of missingRequired) { + console.error(`- ${file}`); + } + } + + if (unexpected.length) { + console.error('Unexpected packed files:'); + for (const file of unexpected) { + console.error(`- ${file}`); + } + } + + process.exit(1); +} + +console.log('npm pack publish shape is valid.'); diff --git a/skills/opal-frontend-common-node/opal-frontend-common-node-repo-guidelines/SKILL.md b/skills/opal-frontend-common-node/opal-frontend-common-node-repo-guidelines/SKILL.md index ec3abf4..c3ae404 100644 --- a/skills/opal-frontend-common-node/opal-frontend-common-node-repo-guidelines/SKILL.md +++ b/skills/opal-frontend-common-node/opal-frontend-common-node-repo-guidelines/SKILL.md @@ -11,11 +11,12 @@ Use these rules to keep work aligned with the library structure, build tooling, ## Project Structure - `src/` contains TypeScript source organized by feature folders (`app-insights/`, `csrf-token/`, `health/`, `helmet/`, `session/`, etc.). - `src/interfaces/` holds shared types; `src/index.ts` re-exports public modules. -- `src/*.d.ts` and `src/global.d.ts` provide ambient typings and are copied to `dist/`. +- `src/*.d.ts` (for example `src/global.d.ts` and `src/session.d.ts`) provide ambient typings and are copied to `dist/` by the build. - `dist/` is build output and should be generated via `yarn build` (do not hand-edit). ## Build, Lint, and Audit Commands -- `yarn build` runs `clean`, compiles TypeScript, and copies `package.json`, root `.d.ts` files, and `README.md` to `dist/`. +- `yarn build` runs `clean`, compiles TypeScript, and copies root ambient `.d.ts` files to `dist/`. +- `yarn pack:local` runs `prepack` (`yarn build`) and creates a fresh local `.tgz` artifact in repo root for consumer testing. - `yarn clean` removes `dist/`. - `yarn lint` runs ESLint over `src/` and Prettier checks. - `yarn prettier` checks formatting; `yarn prettier:fix` formats in place. @@ -29,6 +30,7 @@ Use these rules to keep work aligned with the library structure, build tooling, - `src/index.ts` (top-level re-exports) - `tsconfig.json` `paths` (local TS resolution) - `src//index.ts` (module-local exports) + - `typesVersions` (if needed for legacy TS resolver compatibility) - any required ambient `.d.ts` in `src/` so it is copied to `dist/` ## Coding Style @@ -37,9 +39,11 @@ Use these rules to keep work aligned with the library structure, build tooling, - Prefer small, focused modules; avoid unnecessary side effects at import time. ## Tooling and Environment -- Node.js v18+ and Yarn classic v1.22.22 (per `package.json` and `README.md`). +- Node.js v18+ and Yarn v4.x (Berry) (per `package.json` and `README.md`). - `tsconfig.json` uses strict mode; avoid `any` and keep types explicit. ## Publishing - Bump the version in `package.json`, create a GitHub release with that tag, and wait for the release workflow to publish (see `README.md`). - `dist/` is generated during `yarn build` and is not committed. +- Publish from the repository root `package.json` (single source of truth). Do not copy `package.json` into `dist/`. +- For local consumer testing, install from the generated `.tgz` (`yarn pack:local`) rather than linking the raw repository folder. diff --git a/skills/opal-frontend-common-node/opal-frontend-common-node-review-guidelines/SKILL.md b/skills/opal-frontend-common-node/opal-frontend-common-node-review-guidelines/SKILL.md index c20ad2f..95ddb08 100644 --- a/skills/opal-frontend-common-node/opal-frontend-common-node-review-guidelines/SKILL.md +++ b/skills/opal-frontend-common-node/opal-frontend-common-node-review-guidelines/SKILL.md @@ -17,6 +17,7 @@ Apply these rules when reviewing changes in this Node.js library; focus on P0/P1 ## Repo Scope - This is an ESM TypeScript library built with `tsc` and published via `package.json` exports. +- Runtime baseline is Node.js 18+. - Consumers are Node/Express services in the OPAL ecosystem. ## P0 Rules (Blockers) @@ -32,6 +33,7 @@ Apply these rules when reviewing changes in this Node.js library; focus on P0/P1 - Do not break the published API surface without a version bump and release notes. - Keep `package.json` exports, `src/index.ts`, and `tsconfig.json` paths in sync for any public module change. +- Do not introduce deep-import-only usage that bypasses the `exports` map. - Avoid TypeScript errors, failing lint/prettier, or build regressions. ## P1 Rules (High Priority) From 663c121c049a235409f6eefa0adbdd9b92e82b06 Mon Sep 17 00:00:00 2001 From: Frankie Moran Date: Tue, 24 Feb 2026 13:38:23 +0000 Subject: [PATCH 10/11] Update SKILL content to include some extra stuff --- .../SKILL.md | 18 +++++++++++++++++- .../SKILL.md | 10 ++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/skills/opal-frontend-common-node/opal-frontend-common-node-repo-guidelines/SKILL.md b/skills/opal-frontend-common-node/opal-frontend-common-node-repo-guidelines/SKILL.md index c3ae404..7829d0f 100644 --- a/skills/opal-frontend-common-node/opal-frontend-common-node-repo-guidelines/SKILL.md +++ b/skills/opal-frontend-common-node/opal-frontend-common-node-repo-guidelines/SKILL.md @@ -17,6 +17,10 @@ Use these rules to keep work aligned with the library structure, build tooling, ## Build, Lint, and Audit Commands - `yarn build` runs `clean`, compiles TypeScript, and copies root ambient `.d.ts` files to `dist/`. - `yarn pack:local` runs `prepack` (`yarn build`) and creates a fresh local `.tgz` artifact in repo root for consumer testing. +- `yarn check:exports` validates every `package.json` export target exists in `dist/`. +- `yarn check:exports:esm` smoke-tests ESM import of all public subpaths. +- `yarn check:pack-shape` validates packed tarball contents against `exports`, `types`, and `typesVersions`. +- `npm pack --dry-run` shows exact publish payload; run it whenever package surface changes. - `yarn clean` removes `dist/`. - `yarn lint` runs ESLint over `src/` and Prettier checks. - `yarn prettier` checks formatting; `yarn prettier:fix` formats in place. @@ -25,6 +29,8 @@ Use these rules to keep work aligned with the library structure, build tooling, ## Export Map and Public API - This package is ESM (`"type": "module"`). Keep imports/exports in ESM style. - `package.json` `exports` is the source of truth for published entry points. +- Keep subpath exports explicit and intentional; do not rely on unpublished deep imports into `dist/`. +- Every exported subpath should have both runtime (`.js`) and declaration (`.d.ts`) targets that exist after build and are included in the packed tarball. - When adding or removing a public module, update all of: - `package.json` `exports` - `src/index.ts` (top-level re-exports) @@ -33,6 +39,15 @@ Use these rules to keep work aligned with the library structure, build tooling, - `typesVersions` (if needed for legacy TS resolver compatibility) - any required ambient `.d.ts` in `src/` so it is copied to `dist/` +## Packaging Gate (Required for Package-Surface Changes) +- Apply this gate when changing `exports`, `types`, `typesVersions`, `files`, build output layout, or publish scripts. +- Run: `yarn build`. +- Run: `yarn check:exports`. +- Run: `yarn check:exports:esm`. +- Run: `yarn check:pack-shape`. +- Run: `npm pack --dry-run`. +- Treat any failure as blocking until resolved. + ## Coding Style - Follow `.editorconfig` and `.prettierrc`: 2-space indent, single quotes in TS, 120 print width, semicolons. - Keep class members ordered per `@typescript-eslint/member-ordering` in `eslint.config.js`. @@ -45,5 +60,6 @@ Use these rules to keep work aligned with the library structure, build tooling, ## Publishing - Bump the version in `package.json`, create a GitHub release with that tag, and wait for the release workflow to publish (see `README.md`). - `dist/` is generated during `yarn build` and is not committed. -- Publish from the repository root `package.json` (single source of truth). Do not copy `package.json` into `dist/`. +- Publish from the repository root `package.json` (single source of truth). +- Do not generate or copy a second `package.json` into `dist/`. - For local consumer testing, install from the generated `.tgz` (`yarn pack:local`) rather than linking the raw repository folder. diff --git a/skills/opal-frontend-common-node/opal-frontend-common-node-review-guidelines/SKILL.md b/skills/opal-frontend-common-node/opal-frontend-common-node-review-guidelines/SKILL.md index 95ddb08..3292b8f 100644 --- a/skills/opal-frontend-common-node/opal-frontend-common-node-review-guidelines/SKILL.md +++ b/skills/opal-frontend-common-node/opal-frontend-common-node-review-guidelines/SKILL.md @@ -34,6 +34,10 @@ Apply these rules when reviewing changes in this Node.js library; focus on P0/P1 - Do not break the published API surface without a version bump and release notes. - Keep `package.json` exports, `src/index.ts`, and `tsconfig.json` paths in sync for any public module change. - Do not introduce deep-import-only usage that bypasses the `exports` map. +- Every `package.json` export target (`import` and `types`) must resolve to real files in `dist/`. +- `yarn check:pack-shape` (or `npm pack --dry-run` + equivalent validation) must pass for package-surface changes. +- Packed tarball must include all files referenced by `exports`, `types`, and `typesVersions`, including copied ambient `.d.ts`. +- Do not split publish metadata across multiple manifests (for example by generating `dist/package.json`). - Avoid TypeScript errors, failing lint/prettier, or build regressions. ## P1 Rules (High Priority) @@ -50,6 +54,12 @@ Apply these rules when reviewing changes in this Node.js library; focus on P0/P1 - Keep functions small and single-purpose; extract helpers for readability. - Use consistent error shapes and typed config interfaces. +### Packaging and ESM Compatibility + +- For exported ESM files, ensure runtime-resolved relative imports remain Node-compatible after emit (no broken extensionless paths). +- If a PR tightens exports/subpaths, require a clear consumer impact note and migration import examples. +- Prefer local consumer validation via `yarn pack:local` tarball installs; flag raw-repo-link workflows that bypass publish behavior. + ### Dependency and Performance Risk - Avoid introducing heavy dependencies unless justified; note impact in review. From cf4e72834ee7c0dfa7ad7ce893506d7692063748 Mon Sep 17 00:00:00 2001 From: Frankie Moran Date: Mon, 30 Mar 2026 10:03:03 +0100 Subject: [PATCH 11/11] (bump): version number --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 43016e4..dbcd109 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hmcts/opal-frontend-common-node", "type": "module", - "version": "0.0.27", + "version": "0.0.28", "license": "MIT", "description": "Common nodejs library components for opal", "engines": {