Skip to content

Commit

Permalink
Add Server type for server entrypoint, unit test CSP functionality (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
askoufis authored Jun 27, 2024
1 parent 65df710 commit a92e7e5
Show file tree
Hide file tree
Showing 14 changed files with 274 additions and 525 deletions.
42 changes: 42 additions & 0 deletions .changeset/shaggy-grapes-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'sku': minor
---

Export a `Server` type for `sku`'s server entrypoint

**EXAMPLE USAGE**:

```tsx
// server.tsx
import { renderToString } from 'react-dom/server';
import type { Server } from 'sku';
import { App } from './App';

export default (): Server => ({
renderCallback: ({ SkuProvider, getHeadTags, getBodyTags }, _req, res) => {
const app = renderToString(
<SkuProvider>
<App />
</SkuProvider>,
);

res.send(/* html */ `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>My Awesome Project</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
${getHeadTags()}
</head>
<body>
<div id="app">${app}</div>
${getBodyTags()}
</body>
</html>`);
},
});
```

> [!NOTE]
> The `Server` type may conflict with existing attempts in projects to define a `Server` type.
14 changes: 8 additions & 6 deletions docs/docs/csp.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ In the `renderCallback` function, register all extra script tags (inline and ext
> If you are using multi-part responses via the `flushHeadTags` API, all scripts must be registered before sending the the initial response.
```tsx
async function renderCallback(
{ SkuProvider, getBodyTags, registerScript },
import type { Server } from 'sku';

const renderCallback: Server['renderCallback'] = (
{ SkuProvider, getHeadTags, getBodyTags, registerScript },
req,
res,
) {
) => {
const someExternalScript = `<script src="https://code.jquery.com/jquery-3.5.0.slim.min.js"></script>`;
const someInlineScript = `<script>console.log('Hi');</script>`;

Expand All @@ -43,14 +45,14 @@ async function renderCallback(
<meta charset="UTF-8">
<title>My Awesome Project</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
${headTags}
${getHeadTags()}
</head>
<body>
<div id="app">${app}</div>
${someInlineScript}
${bodyTags}
${getBodyTags()}
${someExternalScript}
</body>
</html>`);
}
};
```
10 changes: 6 additions & 4 deletions docs/docs/server-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ Then, you need to create your `server` entry. Sku will automatically provide an

This can be done as follows:

```js
```tsx
import template from './template';
import middleware from './middleware';
import type { Server } from 'sku';

export default () => ({
export default (): Server => ({
renderCallback: ({ SkuProvider, getBodyTags, getHeadTags }, req, res) => {
const app = renderToString(
<SkuProvider>
Expand All @@ -57,11 +58,12 @@ New head tags can be added during render, typically this is due to dynamic chunk

For example, you may want to send back an initial response before you are done rendering your response:

```js
```tsx
import { initialResponseTemplate, followupResponseTemplate } from './template';
import middleware from './middleware';
import type { Server } from 'sku';

export default () => ({
export default (): Server => ({
renderCallback: ({ SkuProvider, getBodyTags, getHeadTags }, req, res) => {
res.status(200);
// Call `flushHeadTags` early to retrieve whatever tags are available.
Expand Down
14 changes: 3 additions & 11 deletions fixtures/assertion-removal/src/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,10 @@ import * as React from 'react';
import { renderToString } from 'react-dom/server';

import App from './App';
import type { Server } from 'sku';

interface SkuProps {
SkuProvider: React.FunctionComponent<{ children: React.ReactNode }>;
getHeadTags: () => string;
getBodyTags: () => string;
}
export default () => ({
renderCallback: (
{ SkuProvider, getBodyTags, getHeadTags }: SkuProps,
_: any,
res: any,
): void => {
export default (): Server => ({
renderCallback: ({ SkuProvider, getBodyTags, getHeadTags }, _, res) => {
const app = renderToString(
<SkuProvider>
<App />
Expand Down
14 changes: 3 additions & 11 deletions fixtures/styling/src/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,10 @@ import type React from 'react';
import { renderToString } from 'react-dom/server';

import App from './App';
import type { Server } from 'sku';

interface SkuProps {
SkuProvider: React.FunctionComponent<{ children: React.ReactNode }>;
getHeadTags: () => string;
getBodyTags: () => string;
}
export default () => ({
renderCallback: (
{ SkuProvider, getBodyTags, getHeadTags }: SkuProps,
_: any,
res: any,
): void => {
export default (): Server => ({
renderCallback: ({ SkuProvider, getBodyTags, getHeadTags }, _, res): void => {
const app = renderToString(
<SkuProvider>
<App />
Expand Down
13 changes: 6 additions & 7 deletions fixtures/translations/src/server.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { renderToString } from 'react-dom/server';
import { VocabProvider } from '@vocab/react';

import type { Request, Response } from 'express';

import App from './App';
import type { Server } from 'sku';

const initialResponseTemplate = ({ headTags }: any) => /* html */ `
<!DOCTYPE html>
Expand All @@ -25,19 +24,19 @@ const template = ({ headTags, bodyTags, app }: any) => /* html */ `
</html>
`;

export default () => ({
export default (): Server => ({
renderCallback: async (
{ SkuProvider, getBodyTags, flushHeadTags, addLanguageChunk }: any,
req: Request,
res: Response,
{ SkuProvider, getBodyTags, flushHeadTags, addLanguageChunk },
req,
res,
) => {
res.status(200).write(
initialResponseTemplate({
headTags: flushHeadTags(),
}),
);
await Promise.resolve();
const isPseudo = Boolean(req.query['pseudo']);
const isPseudo = Boolean(req.query.pseudo);
const pathLanguage = req.url.includes('fr') ? 'fr' : 'en';
const language = isPseudo ? 'en-PSEUDO' : pathLanguage;
addLanguageChunk(language);
Expand Down
14 changes: 3 additions & 11 deletions fixtures/typescript-css-modules/src/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,10 @@ import type React from 'react';
import { renderToString } from 'react-dom/server';

import App from './App';
import type { Server } from 'sku';

interface SkuProps {
SkuProvider: React.FunctionComponent<{ children: React.ReactNode }>;
getHeadTags: () => string;
getBodyTags: () => string;
}
export default () => ({
renderCallback: (
{ SkuProvider, getBodyTags, getHeadTags }: SkuProps,
_: any,
res: any,
): void => {
export default (): Server => ({
renderCallback: ({ SkuProvider, getBodyTags, getHeadTags }, _, res): void => {
const app = renderToString(
<SkuProvider>
<App />
Expand Down
58 changes: 42 additions & 16 deletions packages/sku/entry/csp.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @ts-check
import { createHash } from 'node:crypto';
import { parse, valid } from 'node-html-parser';
import { URL } from 'node:url';
Expand All @@ -6,9 +7,18 @@ const scriptTypeIgnoreList = ['application/json', 'application/ld+json'];

const defaultBaseName = 'http://relative-url';

/** @typedef {import("node:crypto").BinaryLike} BinaryLike */

/** @param {BinaryLike} scriptContents */
const hashScriptContents = (scriptContents) =>
createHash('sha256').update(scriptContents).digest('base64');

/**
* @typedef {object} CreateCSPHandlerOptions
* @property {string[]} [extraHosts=[]]
* @property {boolean} [isDevelopment=false]
* @param {CreateCSPHandlerOptions} [options={}]
*/
export default function createCSPHandler({
extraHosts = [],
isDevelopment = false,
Expand All @@ -17,10 +27,14 @@ export default function createCSPHandler({
const hosts = new Set();
const shas = new Set();

/** @param {BinaryLike | undefined?} contents */
const addScriptContents = (contents) => {
shas.add(hashScriptContents(contents));
if (contents) {
shas.add(hashScriptContents(contents));
}
};

/** @param {string} src */
const addScriptUrl = (src) => {
const { origin } = new URL(src, defaultBaseName);

Expand All @@ -31,18 +45,22 @@ export default function createCSPHandler({

extraHosts.forEach((host) => addScriptUrl(host));

/** @param {import("node-html-parser").HTMLElement} scriptNode */
const processScriptNode = (scriptNode) => {
const src = scriptNode.getAttribute('src');

if (src) {
addScriptUrl(src);
} else if (
!scriptTypeIgnoreList.includes(scriptNode.getAttribute('type'))
) {
addScriptContents(scriptNode.firstChild.rawText);
return;
}

const scriptType = scriptNode.getAttribute('type');
if (scriptType == null || !scriptTypeIgnoreList.includes(scriptType)) {
addScriptContents(scriptNode.firstChild?.rawText);
}
};

/** @type {import("../sku-types.d.ts").RenderCallbackParams['registerScript']} */
const registerScript = (script) => {
if (tagReturned) {
throw new Error(
Expand All @@ -53,18 +71,16 @@ export default function createCSPHandler({
);
}

if (
process.env.NODE_ENV !== 'production' &&
!valid(script, { script: true })
) {
if (process.env.NODE_ENV !== 'production' && !valid(script)) {
console.error(`Invalid script passed to 'registerScript'\n${script}`);
}

parse(script, { script: true })
.querySelectorAll('script')
.forEach(processScriptNode);
parse(script).querySelectorAll('script').forEach(processScriptNode);
};

/**
* @returns {string}
*/
const createCSPTag = () => {
tagReturned = true;

Expand All @@ -90,11 +106,12 @@ export default function createCSPHandler({
)};">`;
};

/**
* @param {string} html
* @returns {string}
*/
const handleHtml = (html) => {
const root = parse(html, {
script: true,
style: true,
pre: true,
comment: true,
});

Expand All @@ -108,7 +125,16 @@ export default function createCSPHandler({

root.querySelectorAll('script').forEach(processScriptNode);

root.querySelector('head').insertAdjacentHTML('afterbegin', createCSPTag());
const headElement = root.querySelector('head');
if (!headElement) {
throw new Error(
`Unable to find 'head' element in HTML in order to create CSP tag. Check the following output of renderDocument for invalid HTML.\n${
html.length > 250 ? `${html.substring(0, 200)}...` : html
}`,
);
}

headElement.insertAdjacentHTML('afterbegin', createCSPTag());

return root.toString();
};
Expand Down
Loading

0 comments on commit a92e7e5

Please sign in to comment.