@sjolystinnovation/app-kit 0.9.0 → 0.10.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/README.md CHANGED
@@ -309,6 +309,45 @@ export const POST = createRevalidateRoute({
309
309
  });
310
310
  ```
311
311
 
312
+ ### `@sjolystinnovation/app-kit/next-config`
313
+
314
+ For `next.config.ts`. It imports nothing but Next's types, so Next loads it with no build step.
315
+
316
+ `defineAppConfig(options, extra?)` gives the config every app shares: the standalone build, app-kit
317
+ in `transpilePackages`, no `X-Powered-By`, `expireTime` of five minutes, image qualities 75 and 100,
318
+ the incremental cache in memory, and the security headers on every page. `extra` is merged over it,
319
+ with `transpilePackages`, `images`, `headers` and `rewrites` combined rather than replaced.
320
+
321
+ ```ts
322
+ // next.config.ts
323
+ import { defineAppConfig } from "@sjolystinnovation/app-kit/next-config";
324
+
325
+ export default defineAppConfig({
326
+ apiUrl: process.env.NEXT_PUBLIC_API_URL,
327
+ dev: process.env.NODE_ENV !== "production",
328
+ contentImages: true,
329
+ });
330
+ ```
331
+
332
+ | Option | Type | Description |
333
+ | --- | --- | --- |
334
+ | `apiUrl` | `string \| undefined` | The API URL. Its origin is allowed in `connect-src`. |
335
+ | `dev` | `boolean` | Development builds also allow `'unsafe-eval'`, which hot reload needs. |
336
+ | `websocket` | `boolean` | Also allow `ws:` and `wss:` to the API origin, for SignalR. |
337
+ | `imgFromApi` | `boolean` | Also allow images from the API origin. Not needed with `contentImages`. |
338
+ | `contentImages` | `boolean` | Proxy `/content-images/*` to the API's `/content-images/*`, so the browser never contacts the API host. |
339
+ | `connectSrc`, `imgSrc` | `string[]` | Extra sources for those directives. |
340
+ | `frameSrc`, `objectSrc` | `string[]` | Sources for those directives. Both default to `'none'`. |
341
+ | `pathHeaders` | `{ source, headers }[]` | Headers for particular paths, such as a file download. They come after the page headers and replace a page header of the same name. Every path keeps the rest. |
342
+ | `permissionsPolicy` | `string` | Replaces `camera=(), microphone=(), geolocation=()`. |
343
+ | `expireTime` | `number` | Seconds a cache may serve a stale page. Defaults to 300. Next's default is a year. |
344
+ | `isrFlushToDisk` | `boolean` | Write revalidated pages to disk. Off by default, for a read-only root filesystem. |
345
+
346
+ The pieces are exported too: `securityHeaders(options)`, `contentSecurityPolicy(options)`,
347
+ `contentImagesRewrite(apiUrl, { source?, upstreamPath? })`, `apiOrigin(apiUrl)` and
348
+ `apiBaseUrl(apiUrl)`. Scripts allow `'unsafe-inline'`, since a nonce would make every statically
349
+ rendered page dynamic.
350
+
312
351
  ### `@sjolystinnovation/app-kit/session`
313
352
 
314
353
  Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sjølyst Innovation AS",
@@ -25,6 +25,7 @@
25
25
  "./ui": "./src/ui/index.ts",
26
26
  "./toast": "./src/toast/index.ts",
27
27
  "./validation": "./src/validation/index.ts",
28
+ "./next-config": "./src/next-config/index.ts",
28
29
  "./styles.css": "./src/styles.css"
29
30
  },
30
31
  "files": [
@@ -0,0 +1,154 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ /** A response header, as `next.config.ts` lists them. */
4
+ export interface Header {
5
+ key: string;
6
+ value: string;
7
+ }
8
+
9
+ /** The API's origin, such as `https://api.example.com`. Empty when the URL is missing or invalid. */
10
+ export function apiOrigin(apiUrl: string | undefined): string {
11
+ if (!apiUrl) return "";
12
+ try {
13
+ return new URL(apiUrl).origin;
14
+ } catch {
15
+ return "";
16
+ }
17
+ }
18
+
19
+ /** The API's base URL with its path, such as `https://api.example.com/api`, without a trailing slash. Empty when missing or invalid. */
20
+ export function apiBaseUrl(apiUrl: string | undefined): string {
21
+ if (!apiUrl) return "";
22
+ try {
23
+ return new URL(apiUrl).toString().replace(/\/$/, "");
24
+ } catch {
25
+ return "";
26
+ }
27
+ }
28
+
29
+ export interface CspOptions {
30
+ /** The app's API URL, usually `process.env.NEXT_PUBLIC_API_URL`. Its origin may be fetched from. */
31
+ apiUrl: string | undefined;
32
+ /** Development builds also allow `'unsafe-eval'`, which hot reload needs. */
33
+ dev: boolean;
34
+ /** Also allow `ws:` and `wss:` to the API, for a WebSocket such as SignalR. */
35
+ websocket?: boolean | undefined;
36
+ /** Also allow images from the API origin. Not needed when images go through the content images rewrite. */
37
+ imgFromApi?: boolean | undefined;
38
+ /** Extra sources for `connect-src`. */
39
+ connectSrc?: readonly string[] | undefined;
40
+ /** Extra sources for `img-src`. */
41
+ imgSrc?: readonly string[] | undefined;
42
+ /** Sources for `frame-src`. Defaults to `'none'`, since no page embeds a frame. */
43
+ frameSrc?: readonly string[] | undefined;
44
+ /** Sources for `object-src`. Defaults to `'none'`, since no page embeds an object. */
45
+ objectSrc?: readonly string[] | undefined;
46
+ }
47
+
48
+ const sources = (list: readonly (string | false | undefined)[]) => list.filter(Boolean).join(" ");
49
+
50
+ /**
51
+ * The Content-Security-Policy for an app's pages. Scripts allow `'unsafe-inline'`, because a nonce
52
+ * would make every statically rendered page dynamic.
53
+ */
54
+ export function contentSecurityPolicy(o: CspOptions): string {
55
+ const origin = apiOrigin(o.apiUrl);
56
+ const wsOrigin = o.websocket ? origin.replace(/^https:/, "wss:").replace(/^http:/, "ws:") : "";
57
+ return [
58
+ "default-src 'self'",
59
+ o.dev ? "script-src 'self' 'unsafe-eval' 'unsafe-inline'" : "script-src 'self' 'unsafe-inline'",
60
+ "style-src 'self' 'unsafe-inline'",
61
+ `img-src ${sources(["'self'", o.imgFromApi && origin, "data:", "blob:", ...(o.imgSrc ?? [])])}`,
62
+ `connect-src ${sources(["'self'", origin, wsOrigin, ...(o.connectSrc ?? [])])}`,
63
+ `frame-src ${sources(o.frameSrc ?? ["'none'"])}`,
64
+ `object-src ${sources(o.objectSrc ?? ["'none'"])}`,
65
+ "font-src 'self'",
66
+ "base-uri 'self'",
67
+ "form-action 'self'",
68
+ "frame-ancestors 'none'",
69
+ "upgrade-insecure-requests",
70
+ ].join("; ");
71
+ }
72
+
73
+ /** The security headers for an app's pages, including the Content-Security-Policy. */
74
+ export function securityHeaders(o: CspOptions & { permissionsPolicy?: string | undefined }): Header[] {
75
+ return [
76
+ { key: "X-Content-Type-Options", value: "nosniff" },
77
+ { key: "X-Frame-Options", value: "DENY" },
78
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
79
+ { key: "X-DNS-Prefetch-Control", value: "on" },
80
+ { key: "Permissions-Policy", value: o.permissionsPolicy ?? "camera=(), microphone=(), geolocation=()" },
81
+ { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
82
+ { key: "Content-Security-Policy", value: contentSecurityPolicy(o) },
83
+ ];
84
+ }
85
+
86
+ /**
87
+ * Content images, proxied so the browser never contacts the API host. A rewrite rather than a route
88
+ * handler, so the query string and the Accept header, which webp needs, pass through. No rewrite
89
+ * when the API URL is missing or invalid.
90
+ */
91
+ export function contentImagesRewrite(apiUrl: string | undefined, o: { source?: string | undefined; upstreamPath?: string | undefined } = {}) {
92
+ const base = apiBaseUrl(apiUrl);
93
+ if (!base) return [];
94
+ const source = o.source ?? "/content-images";
95
+ const upstream = o.upstreamPath ?? "/content-images";
96
+ return [{ source: `${source}/:path*`, destination: `${base}${upstream}/:path*` }];
97
+ }
98
+
99
+ export interface AppConfigOptions extends CspOptions {
100
+ /** Proxy `/content-images/*` to the API. See `contentImagesRewrite`. */
101
+ contentImages?: boolean | undefined;
102
+ /**
103
+ * Headers for particular paths. Every path gets the page headers, and these come after them, so
104
+ * they replace a page header of the same name.
105
+ */
106
+ pathHeaders?: readonly { source: string; headers: Header[] }[] | undefined;
107
+ /** Replaces the default Permissions-Policy. */
108
+ permissionsPolicy?: string | undefined;
109
+ /**
110
+ * Seconds a cache may go on serving a page after it goes stale. Next's default is a year, long
111
+ * enough for a browser to hand back an old page and fetch the current one behind it. Defaults to
112
+ * 300, matching the client router cache's stale time.
113
+ */
114
+ expireTime?: number | undefined;
115
+ /**
116
+ * Write revalidated pages to disk. Off by default: with a read-only root filesystem Next cannot
117
+ * write to `.next/server/app`, so the incremental cache stays in memory.
118
+ */
119
+ isrFlushToDisk?: boolean | undefined;
120
+ }
121
+
122
+ /**
123
+ * The shared Next config for an app: the standalone build, app-kit transpiled, no `X-Powered-By`,
124
+ * the security headers on every page, and optionally the content images rewrite. `extra` is merged
125
+ * over it, with `transpilePackages` and `images` combined.
126
+ */
127
+ export function defineAppConfig(o: AppConfigOptions, extra: NextConfig = {}): NextConfig {
128
+ const pageHeaders = securityHeaders(o);
129
+ const paths = o.pathHeaders ?? [];
130
+
131
+ const { transpilePackages, images, experimental, headers: extraHeaders, rewrites: extraRewrites, ...rest } = extra;
132
+ return {
133
+ output: "standalone",
134
+ poweredByHeader: false,
135
+ expireTime: o.expireTime ?? 300,
136
+ ...rest,
137
+ transpilePackages: [...new Set(["@sjolystinnovation/app-kit", ...(transpilePackages ?? [])])],
138
+ images: { qualities: [75, 100], ...images },
139
+ experimental: { isrFlushToDisk: o.isrFlushToDisk ?? false, ...experimental },
140
+ async headers() {
141
+ // Every path gets the page headers. Next lets a later rule replace a header of the same name,
142
+ // so a path's own headers come after them.
143
+ const own = [{ source: "/:path*", headers: pageHeaders }, ...paths.map(p => ({ source: p.source, headers: p.headers }))];
144
+ return [...own, ...((await extraHeaders?.()) ?? [])];
145
+ },
146
+ async rewrites() {
147
+ const own = o.contentImages ? contentImagesRewrite(o.apiUrl) : [];
148
+ const more = await extraRewrites?.();
149
+ if (!more) return own;
150
+ if (Array.isArray(more)) return [...own, ...more];
151
+ return { ...more, afterFiles: [...own, ...(more.afterFiles ?? [])] };
152
+ },
153
+ };
154
+ }