Skip to content

Commit

Permalink
Happy-DOM version of NAV Dekoratøren
Browse files Browse the repository at this point in the history
  • Loading branch information
cskrov committed Aug 27, 2024
1 parent 0e1963a commit d9782b3
Show file tree
Hide file tree
Showing 12 changed files with 266 additions and 26 deletions.
6 changes: 6 additions & 0 deletions .github/workflows/deploy-to-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ jobs:
cd server
bun test
- name: Build server
shell: bash
run: |
cd server
bun run build
- name: Generate version number
id: version
run: echo "version=$(TZ="Europe/Oslo" git show -s --format=%cd --date='format-local:%Y-%m-%dT%H:%M:%S')" >> "$GITHUB_OUTPUT"
Expand Down
7 changes: 2 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
FROM node:22-alpine

ENV NODE_ENV=production
ENV NODE_ENV production
ENV NPM_CONFIG_CACHE=/tmp

WORKDIR /usr/src/app
Expand All @@ -9,11 +9,8 @@ COPY frontend frontend

WORKDIR /usr/src/app/server

# This command will create an absolute path reference to JSDOM (used by nav-dekoratoren-moduler), which will not work if built outside Docker
RUN npm run build

ARG VERSION
ENV VERSION=$VERSION
ENV VERSION $VERSION

CMD node dist/server.js
EXPOSE 8080
6 changes: 5 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<script async type="module" src="/src/index.tsx"></script>
{{DECORATOR_STYLES}}
{{DECORATOR_SCRIPTS}}
<title>Klage eller anke på vedtak</title>
</head>

<body>
{{DECORATOR_HEADER}}
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
{{DECORATOR_FOOTER}}
</body>

</html>
</html>
Binary file modified server/bun.lockb
Binary file not shown.
9 changes: 5 additions & 4 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,25 @@
"private": true,
"author": "NAV",
"license": "MIT",
"main": "dist/server.js",
"type": "module",
"scripts": {
"build": "tsc && tsc-alias",
"start": "bun run build --watch & node --watch --trace-warnings dist/server.js",
"build": "bun build ./src/server.ts --target node --format esm --sourcemap --outdir dist",
"typecheck": "tsc --noEmit",
"lint": "biome check"
},
"dependencies": {
"@fastify/cors": "9.0.1",
"@fastify/http-proxy": "9.5.0",
"@fastify/type-provider-typebox": "4.1.0",
"@navikt/nav-dekoratoren-moduler": "1.6.9",
"fastify": "4.28.1",
"fastify-metrics": "11.0.0",
"happy-dom": "^15.0.0",
"jose": "5.8.0",
"openid-client": "5.6.5",
"prom-client": "15.1.3",
"redis": "4.7.0",
"tsc-alias": "1.8.10"
"redis": "4.7.0"
},
"devDependencies": {
"@biomejs/biome": "1.8.3",
Expand Down
4 changes: 2 additions & 2 deletions server/src/config/version.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isDeployed } from './env';
import { requiredEnvString } from './env-var';
import { isDeployed } from '@app/config/env';
import { requiredEnvString } from '@app/config/env-var';

const getDefaultVersion = () => {
if (isDeployed) {
Expand Down
31 changes: 19 additions & 12 deletions server/src/index-file.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import { frontendDistDirectoryPath } from '@app/config/config';
import { ENVIRONMENT, isDeployedToProd } from '@app/config/env';
import { VERSION } from '@app/config/version';
import { getLogger } from '@app/logger';
import { fetchDecoratorHtml } from '@app/nav-dekoratoren/nav-dekoratoren';
import { EmojiIcons, sendToSlack } from '@app/slack';
import { injectDecoratorServerSide } from '@navikt/nav-dekoratoren-moduler/ssr';

const log = getLogger('index-file');

class IndexFile {
private readonly INDEX_HTML_PATH = path.join(frontendDistDirectoryPath, 'index.html');
private readonly INDEX_HTML = readFileSync(path.join(frontendDistDirectoryPath, 'index.html'), 'utf-8');

private _isReady = false;
public get isReady() {
Expand Down Expand Up @@ -40,21 +41,27 @@ class IndexFile {
try {
const start = performance.now();

const indexHtml = await injectDecoratorServerSide({
const { DECORATOR_SCRIPTS, DECORATOR_STYLES, DECORATOR_HEADER, DECORATOR_FOOTER } = await fetchDecoratorHtml({
env: isDeployedToProd ? 'prod' : 'dev',
filePath: this.INDEX_HTML_PATH,
simple: true,
chatbot: true,
redirectToApp: true,
logoutUrl: '/oauth2/logout',
context: 'privatperson',
level: 'Level4',
utloggingsvarsel: true,
params: {
simple: true,
chatbot: true,
redirectToApp: true,
logoutUrl: '/oauth2/logout',
context: 'privatperson',
level: 'Level4',
logoutWarning: true,
},
});

const end = performance.now();

this._indexFile = indexHtml.replace('{{ENVIRONMENT}}', ENVIRONMENT).replace('{{VERSION}}', VERSION);
this._indexFile = this.INDEX_HTML.replace('{{DECORATOR_SCRIPTS}}', DECORATOR_SCRIPTS)
.replace('{{DECORATOR_STYLES}}', DECORATOR_STYLES)
.replace('{{DECORATOR_HEADER}}', DECORATOR_HEADER)
.replace('{{DECORATOR_FOOTER}}', DECORATOR_FOOTER)
.replace('{{ENVIRONMENT}}', ENVIRONMENT)
.replace('{{VERSION}}', VERSION);

log.debug({
msg: 'Successfully updated index.html with Dekoratøren and variables.',
Expand Down
22 changes: 22 additions & 0 deletions server/src/nav-dekoratoren/csr-elements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { DecoratorFetchProps, DecoratorUrlProps } from '@app/nav-dekoratoren/types';
import { getDecoratorUrl } from '@app/nav-dekoratoren/urls';

export const getCsrElements = (csrProps: DecoratorFetchProps) => {
const props: DecoratorUrlProps = {
...csrProps,
csr: true,
};

const envUrl = getDecoratorUrl(props);
const assetsUrl = getDecoratorUrl({ ...props, params: undefined });
const scriptSrc = `${assetsUrl}/client.js`;

return {
header: '<div id="decorator-header"></div>',
footer: '<div id="decorator-footer"></div>',
env: `<div id="decorator-env" data-src="${envUrl}"></div>`,
styles: `<link href="${assetsUrl}/css/client.css" rel="stylesheet" />`,
scripts: `<script src="${scriptSrc}" async></script>`,
scriptSrc,
};
};
81 changes: 81 additions & 0 deletions server/src/nav-dekoratoren/nav-dekoratoren.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { getCsrElements } from '@app/nav-dekoratoren/csr-elements';
import type { DecoratorFetchProps } from '@app/nav-dekoratoren/types';
import { getDecoratorUrl } from '@app/nav-dekoratoren/urls';
import { Window } from 'happy-dom';

export type DecoratorElements = {
DECORATOR_STYLES: string;
DECORATOR_SCRIPTS: string;
DECORATOR_HEADER: string;
DECORATOR_FOOTER: string;
};

const fetchDecorator = async (url: string, props: DecoratorFetchProps, retries = 3): Promise<DecoratorElements> =>
fetch(url)
.then((res) => {
if (res.ok) {
return res.text();
}
throw new Error(`${res.status} - ${res.statusText}`);
})
.then((html) => {
const window = new Window();
window.document.write(html);
const { document } = window;

const styles = document.getElementById('styles')?.innerHTML;
if (!styles) {
throw new Error('Decorator styles element not found!');
}

const scripts = document.getElementById('scripts')?.innerHTML;
if (!scripts) {
throw new Error('Decorator scripts element not found!');
}

const header = document.getElementById('header-withmenu')?.innerHTML;
if (!header) {
throw new Error('Decorator header element not found!');
}

const footer = document.getElementById('footer-withmenu')?.innerHTML;
if (!footer) {
throw new Error('Decorator footer element not found!');
}

const elements = {
DECORATOR_STYLES: styles.trim(),
DECORATOR_SCRIPTS: scripts.trim(),
DECORATOR_HEADER: header.trim(),
DECORATOR_FOOTER: footer.trim(),
};

return elements;
})
.catch((e) => {
if (retries > 0) {
console.warn(`Failed to fetch decorator, retrying ${retries} more times - Url: ${url} - Error: ${e}`);
return fetchDecorator(url, props, retries - 1);
}

throw e;
});

export const fetchDecoratorHtml = async (props: DecoratorFetchProps): Promise<DecoratorElements> => {
const url = getDecoratorUrl(props);

return fetchDecorator(url, props).catch((e) => {
console.error(
`Failed to fetch decorator, falling back to elements for client-side rendering - Url: ${url} - Error: ${e}`,
);

const csrElements = getCsrElements(props);

return {
DECORATOR_STYLES: csrElements.styles,
DECORATOR_SCRIPTS: `${csrElements.env}${csrElements.scripts}`,
DECORATOR_HEADER: csrElements.header,
DECORATOR_FOOTER: csrElements.footer,
};
});
};
58 changes: 58 additions & 0 deletions server/src/nav-dekoratoren/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
export type DecoratorLocale = 'nb' | 'nn' | 'en' | 'se' | 'pl' | 'uk' | 'ru';

export type DecoratorLanguageOption =
| {
url?: string;
locale: DecoratorLocale;
handleInApp: true;
}
| {
url: string;
locale: DecoratorLocale;
handleInApp?: false;
};

export type DecoratorBreadcrumb = {
url: string;
title: string;
analyticsTitle?: string;
handleInApp?: boolean;
};

export type DecoratorNaisEnv = 'prod' | 'dev' | 'beta' | 'betaTms' | 'devNext' | 'prodNext';

export type DecoratorEnvProps =
| { env: 'localhost'; localUrl: string }
| { env: DecoratorNaisEnv; serviceDiscovery?: boolean };

export type DecoratorFetchProps = {
params?: DecoratorParams;
noCache?: boolean;
} & DecoratorEnvProps;

export type DecoratorUrlProps = {
csr?: boolean;
} & DecoratorFetchProps;

export type DecoratorParams = Partial<{
context: 'privatperson' | 'arbeidsgiver' | 'samarbeidspartner';
simple: boolean;
simpleHeader: boolean;
simpleFooter: boolean;
enforceLogin: boolean;
redirectToApp: boolean;
redirectToUrl: string;
redirectToUrlLogout: string;
level: string;
language: DecoratorLocale;
availableLanguages: DecoratorLanguageOption[];
breadcrumbs: DecoratorBreadcrumb[];
utilsBackground: 'white' | 'gray' | 'transparent';
feedback: boolean;
chatbot: boolean;
chatbotVisible: boolean;
urlLookupTable: boolean;
shareScreen: boolean;
logoutUrl: string;
logoutWarning: boolean;
}>;
64 changes: 64 additions & 0 deletions server/src/nav-dekoratoren/urls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { NAIS_CLUSTER_NAME } from '@app/config/config';
import type {
DecoratorBreadcrumb,
DecoratorLanguageOption,
DecoratorNaisEnv,
DecoratorUrlProps,
} from '@app/nav-dekoratoren/types';

type NaisUrls = Record<DecoratorNaisEnv, string>;

const externalUrls: NaisUrls = {
prod: 'https://www.nav.no/dekoratoren',
dev: 'https://dekoratoren.ekstern.dev.nav.no',
beta: 'https://dekoratoren-beta.intern.dev.nav.no',
betaTms: 'https://dekoratoren-beta-tms.intern.dev.nav.no',
devNext: 'https://decorator-next.ekstern.dev.nav.no',
prodNext: 'https://www.nav.no/decorator-next',
};

const serviceUrls: NaisUrls = {
prod: 'http://nav-dekoratoren.personbruker',
dev: 'http://nav-dekoratoren.personbruker',
beta: 'http://nav-dekoratoren-beta.personbruker',
betaTms: 'http://nav-dekoratoren-beta-tms.personbruker',
devNext: 'http://decorator-next.personbruker',
prodNext: 'http://decorator-next.personbruker',
};

const naisGcpClusters: Record<string, true> = {
'dev-gcp': true,
'prod-gcp': true,
};

const objectToQueryString = (
params: Record<string, boolean | string | DecoratorLanguageOption[] | DecoratorBreadcrumb[]>,
) =>
params
? Object.entries(params).reduce(
(acc, [k, v], i) =>
v !== undefined
? `${acc}${i ? '&' : '?'}${k}=${encodeURIComponent(typeof v === 'object' ? JSON.stringify(v) : v)}`
: acc,
'',
)
: '';

const isNaisApp = () => typeof process !== 'undefined' && NAIS_CLUSTER_NAME && naisGcpClusters[NAIS_CLUSTER_NAME];

const getNaisUrl = (env: DecoratorNaisEnv, csr = false, serviceDiscovery = true) => {
const shouldUseServiceDiscovery = serviceDiscovery && !csr && isNaisApp();

return (shouldUseServiceDiscovery ? serviceUrls[env] : externalUrls[env]) || externalUrls.prod;
};

export const getDecoratorUrl = (props: DecoratorUrlProps) => {
const { env, params, csr } = props;
const baseUrl = env === 'localhost' ? props.localUrl : getNaisUrl(env, csr, props.serviceDiscovery);

if (!params) {
return baseUrl;
}

return `${baseUrl}/${csr ? 'env' : ''}${objectToQueryString(params)}`;
};
4 changes: 2 additions & 2 deletions server/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
// Enable latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "NodeNext",
"module": "ESNext",
"moduleDetection": "force",
// Bundler mode
"moduleResolution": "NodeNext",
"moduleResolution": "Bundler",
// Best practices
"strict": true,
"skipLibCheck": true,
Expand Down

0 comments on commit d9782b3

Please sign in to comment.