@payloadcms/figma 0.1.0-alpha.7 → 0.1.0-alpha.8
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/dist/oauth/components/LoginButton/index.js +66 -19
- package/dist/oauth/components/LoginButton/index.scss +121 -15
- package/dist/utils/adapters/nitro.js +39 -23
- package/dist/utils/adapters/vite.js +15 -1
- package/dist/utils/asset-collection.d.ts +1 -1
- package/dist/utils/asset-collection.js +18 -6
- package/dist/utils/build-lambda-zip.js +48 -16
- package/dist/utils/configureNextjsCache.d.ts +7 -0
- package/dist/utils/configureNextjsCache.js +55 -0
- package/dist/utils/fs-utils.d.ts +31 -4
- package/dist/utils/fs-utils.js +95 -15
- package/dist/utils/lambda-runtime/nextjs-cache.d.ts +22 -0
- package/dist/utils/lambda-runtime/nextjs-cache.js +162 -0
- package/package.json +1 -1
|
@@ -6,6 +6,17 @@ import { getSearchParam } from '../../utils/getSearchParam.js';
|
|
|
6
6
|
import { resolveParentTargetOrigin, setupIframeAutoLogin } from './iframeAutoLogin.js';
|
|
7
7
|
import './index.scss';
|
|
8
8
|
const baseClass = 'oauth-login';
|
|
9
|
+
const COLLECTION_SKELETON_KEYS = [
|
|
10
|
+
'collection-1',
|
|
11
|
+
'collection-2',
|
|
12
|
+
'collection-3',
|
|
13
|
+
'collection-4'
|
|
14
|
+
];
|
|
15
|
+
const GLOBAL_SKELETON_KEYS = [
|
|
16
|
+
'global-1',
|
|
17
|
+
'global-2',
|
|
18
|
+
'global-3'
|
|
19
|
+
];
|
|
9
20
|
// Max time we wait for the parent (figma.com) to respond with a code after
|
|
10
21
|
// posting `mint-cms-oauth-code`. If we hit this without a reply, we assume
|
|
11
22
|
// the parent isn't going to respond and fall back to the manual login button.
|
|
@@ -49,6 +60,57 @@ const FigmaIcon = ()=>/*#__PURE__*/ _jsx("svg", {
|
|
|
49
60
|
fillRule: "evenodd"
|
|
50
61
|
})
|
|
51
62
|
});
|
|
63
|
+
const PayloadAdminLoadingShimmer = ()=>/*#__PURE__*/ _jsx("div", {
|
|
64
|
+
"aria-label": "Signing in",
|
|
65
|
+
"aria-live": "polite",
|
|
66
|
+
className: `${baseClass}__admin-shimmer`,
|
|
67
|
+
role: "status",
|
|
68
|
+
children: /*#__PURE__*/ _jsxs("div", {
|
|
69
|
+
"aria-hidden": "true",
|
|
70
|
+
className: `${baseClass}__admin-shimmer-main`,
|
|
71
|
+
children: [
|
|
72
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
73
|
+
className: `${baseClass}__shimmer-heading-row`,
|
|
74
|
+
children: [
|
|
75
|
+
/*#__PURE__*/ _jsx("div", {
|
|
76
|
+
className: `${baseClass}__shimmer-block ${baseClass}__shimmer-heading-icon`
|
|
77
|
+
}),
|
|
78
|
+
/*#__PURE__*/ _jsx("div", {
|
|
79
|
+
className: `${baseClass}__shimmer-block ${baseClass}__shimmer-heading-label`
|
|
80
|
+
})
|
|
81
|
+
]
|
|
82
|
+
}),
|
|
83
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
84
|
+
className: `${baseClass}__shimmer-section`,
|
|
85
|
+
children: [
|
|
86
|
+
/*#__PURE__*/ _jsx("div", {
|
|
87
|
+
className: `${baseClass}__shimmer-block ${baseClass}__shimmer-section-label`
|
|
88
|
+
}),
|
|
89
|
+
/*#__PURE__*/ _jsx("div", {
|
|
90
|
+
className: `${baseClass}__shimmer-card-grid`,
|
|
91
|
+
children: COLLECTION_SKELETON_KEYS.map((key)=>/*#__PURE__*/ _jsx("div", {
|
|
92
|
+
className: `${baseClass}__shimmer-block ${baseClass}__shimmer-card`
|
|
93
|
+
}, key))
|
|
94
|
+
})
|
|
95
|
+
]
|
|
96
|
+
}),
|
|
97
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
98
|
+
className: `${baseClass}__shimmer-section`,
|
|
99
|
+
children: [
|
|
100
|
+
/*#__PURE__*/ _jsx("div", {
|
|
101
|
+
className: `${baseClass}__shimmer-block ${baseClass}__shimmer-section-label`
|
|
102
|
+
}),
|
|
103
|
+
/*#__PURE__*/ _jsx("div", {
|
|
104
|
+
className: `${baseClass}__shimmer-card-grid`,
|
|
105
|
+
children: GLOBAL_SKELETON_KEYS.map((key)=>/*#__PURE__*/ _jsx("div", {
|
|
106
|
+
className: `${baseClass}__shimmer-block ${baseClass}__shimmer-card`
|
|
107
|
+
}, key))
|
|
108
|
+
})
|
|
109
|
+
]
|
|
110
|
+
})
|
|
111
|
+
]
|
|
112
|
+
})
|
|
113
|
+
});
|
|
52
114
|
export const DefaultLoginButton = ({ disabled, endpointSlug })=>{
|
|
53
115
|
const { config: { admin: { user: userSlug }, routes: { api }, serverURL } } = useConfig();
|
|
54
116
|
const [authorizeURL, setAuthorizeURL] = useState('');
|
|
@@ -150,26 +212,11 @@ export const DefaultLoginButton = ({ disabled, endpointSlug })=>{
|
|
|
150
212
|
endpointSlug
|
|
151
213
|
]);
|
|
152
214
|
if (!shouldRenderButton) {
|
|
153
|
-
// In the iframe auto-login window, show a
|
|
154
|
-
//
|
|
155
|
-
//
|
|
215
|
+
// In the iframe auto-login window, show a Payload admin-shaped shimmer
|
|
216
|
+
// until the flow either navigates them away or times out into the manual
|
|
217
|
+
// button.
|
|
156
218
|
if (isInIframe === true && !disabled) {
|
|
157
|
-
return /*#__PURE__*/ _jsx(
|
|
158
|
-
className: baseClass,
|
|
159
|
-
children: /*#__PURE__*/ _jsxs("div", {
|
|
160
|
-
className: `${baseClass}__signing-in`,
|
|
161
|
-
role: "status",
|
|
162
|
-
children: [
|
|
163
|
-
/*#__PURE__*/ _jsx("span", {
|
|
164
|
-
children: "Signing in"
|
|
165
|
-
}),
|
|
166
|
-
/*#__PURE__*/ _jsx("div", {
|
|
167
|
-
"aria-hidden": "true",
|
|
168
|
-
className: `${baseClass}__spinner`
|
|
169
|
-
})
|
|
170
|
-
]
|
|
171
|
-
})
|
|
172
|
-
});
|
|
219
|
+
return /*#__PURE__*/ _jsx(PayloadAdminLoadingShimmer, {});
|
|
173
220
|
}
|
|
174
221
|
return null;
|
|
175
222
|
}
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
.oauth-login,
|
|
2
|
+
.oauth-login__admin-shimmer {
|
|
3
|
+
--oauth-login-shimmer-bg: var(--theme-bg, var(--theme-elevation-0, #ffffff));
|
|
4
|
+
--oauth-login-shimmer-block: var(--theme-elevation-100, #efefef);
|
|
5
|
+
--oauth-login-shimmer-highlight: var(--theme-elevation-200, #f7f7f7);
|
|
6
|
+
}
|
|
7
|
+
|
|
1
8
|
.oauth-login {
|
|
2
9
|
display: flex;
|
|
3
10
|
justify-content: center;
|
|
@@ -55,28 +62,127 @@
|
|
|
55
62
|
Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
|
-
&
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
&__admin-shimmer {
|
|
66
|
+
position: fixed;
|
|
67
|
+
inset: 0;
|
|
68
|
+
z-index: 1000;
|
|
69
|
+
overflow: hidden;
|
|
70
|
+
background: var(--oauth-login-shimmer-bg);
|
|
64
71
|
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
|
65
72
|
Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
66
73
|
}
|
|
67
74
|
|
|
68
|
-
&
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
&__admin-shimmer-main {
|
|
76
|
+
padding: 2rem 2.5rem;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
&__shimmer-block {
|
|
80
|
+
position: relative;
|
|
81
|
+
overflow: hidden;
|
|
82
|
+
border-radius: 0.25rem;
|
|
83
|
+
background-color: var(--oauth-login-shimmer-block);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
&__shimmer-block::after {
|
|
87
|
+
content: '';
|
|
88
|
+
position: absolute;
|
|
89
|
+
inset: 0;
|
|
90
|
+
border-radius: inherit;
|
|
91
|
+
background-image: linear-gradient(
|
|
92
|
+
100deg,
|
|
93
|
+
transparent 0%,
|
|
94
|
+
transparent 35%,
|
|
95
|
+
var(--oauth-login-shimmer-highlight) 50%,
|
|
96
|
+
transparent 65%,
|
|
97
|
+
transparent 100%
|
|
98
|
+
);
|
|
99
|
+
pointer-events: none;
|
|
100
|
+
transform: translateX(-100%);
|
|
101
|
+
animation: oauth-login-shimmer 1.45s linear infinite;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
&__shimmer-heading-row {
|
|
105
|
+
display: flex;
|
|
106
|
+
align-items: center;
|
|
107
|
+
gap: 0.5rem;
|
|
108
|
+
height: 1.75rem;
|
|
109
|
+
margin-bottom: 1rem;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
&__shimmer-heading-icon {
|
|
113
|
+
width: 1.5rem;
|
|
114
|
+
height: 1.5rem;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
&__shimmer-heading-label {
|
|
118
|
+
width: 7.5rem;
|
|
119
|
+
height: 1.25rem;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
&__shimmer-section {
|
|
123
|
+
margin-bottom: 1.5rem;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
&__shimmer-section-label {
|
|
127
|
+
width: 7.5rem;
|
|
128
|
+
height: 1.25rem;
|
|
129
|
+
margin-bottom: 0.5rem;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
&__shimmer-card-grid {
|
|
133
|
+
display: grid;
|
|
134
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
135
|
+
gap: 1rem;
|
|
136
|
+
max-width: 51.625rem;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
&__shimmer-card {
|
|
140
|
+
height: 4rem;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@media (prefers-color-scheme: dark) {
|
|
145
|
+
.oauth-login,
|
|
146
|
+
.oauth-login__admin-shimmer {
|
|
147
|
+
--oauth-login-shimmer-bg: var(--theme-bg, var(--theme-elevation-0, #2b2b2b));
|
|
148
|
+
--oauth-login-shimmer-block: var(--theme-elevation-100, #383838);
|
|
149
|
+
--oauth-login-shimmer-highlight: var(--theme-elevation-200, #444444);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@media (max-width: 48rem) {
|
|
154
|
+
.oauth-login {
|
|
155
|
+
&__shimmer-card-grid {
|
|
156
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
157
|
+
}
|
|
75
158
|
}
|
|
76
159
|
}
|
|
77
160
|
|
|
78
|
-
@
|
|
161
|
+
@media (max-width: 30rem) {
|
|
162
|
+
.oauth-login {
|
|
163
|
+
&__admin-shimmer-main {
|
|
164
|
+
padding-right: 0.75rem;
|
|
165
|
+
padding-left: 0.75rem;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
&__shimmer-card-grid {
|
|
169
|
+
grid-template-columns: 1fr;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
@media (prefers-reduced-motion: reduce) {
|
|
175
|
+
.oauth-login__shimmer-block::after {
|
|
176
|
+
animation: none;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@keyframes oauth-login-shimmer {
|
|
181
|
+
from {
|
|
182
|
+
transform: translateX(-100%);
|
|
183
|
+
}
|
|
184
|
+
|
|
79
185
|
to {
|
|
80
|
-
transform:
|
|
186
|
+
transform: translateX(100%);
|
|
81
187
|
}
|
|
82
188
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import archiver from 'archiver';
|
|
2
|
-
import { createWriteStream
|
|
2
|
+
import { createWriteStream } from 'fs';
|
|
3
3
|
import fs from 'fs/promises';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { resolveNitroOutputLayout } from '../deploy-output/resolveNitroOutputLayout.js';
|
|
6
6
|
import { resolveOutputPath } from '../deploy-output/resolveOutputPath.js';
|
|
7
|
-
import { collectFilesRecursive,
|
|
7
|
+
import { collectFilesRecursive, EscapingSymlinkError, walkBundleFiles } from '../fs-utils.js';
|
|
8
8
|
/** Framework asset directories that are not pre-rendered pages. */ const NON_PAGE_DIRS = new Set([
|
|
9
9
|
'_build',
|
|
10
10
|
'_nuxt',
|
|
@@ -57,8 +57,8 @@ export class NitroAdapter {
|
|
|
57
57
|
throw new Error(`Nitro server build not found at ${layout.outputPath}`);
|
|
58
58
|
}
|
|
59
59
|
const serverDir = path.dirname(layout.serverEntryPath);
|
|
60
|
-
const serverPrefix = path.relative(layout.outputPath, serverDir).split(path.sep).join('/');
|
|
61
|
-
const publicPrefix = path.relative(layout.outputPath, layout.publicPath).split(path.sep).join('/');
|
|
60
|
+
const serverPrefix = zipPrefix(path.relative(layout.outputPath, serverDir).split(path.sep).join('/'));
|
|
61
|
+
const publicPrefix = zipPrefix(path.relative(layout.outputPath, layout.publicPath).split(path.sep).join('/'));
|
|
62
62
|
const serverEntry = path.relative(layout.outputPath, layout.serverEntryPath).split(path.sep).join('/');
|
|
63
63
|
const runShPath = path.join(layout.outputPath, 'run.sh');
|
|
64
64
|
await fs.writeFile(runShPath, createRunScript(serverEntry), {
|
|
@@ -66,22 +66,32 @@ export class NitroAdapter {
|
|
|
66
66
|
});
|
|
67
67
|
const zipPath = path.join(projectPath, 'lambda.zip');
|
|
68
68
|
try {
|
|
69
|
+
const runShStat = await fs.stat(runShPath);
|
|
70
|
+
const [serverFiles, publicFiles] = await Promise.all([
|
|
71
|
+
walkBundleFiles(serverDir),
|
|
72
|
+
walkPublicBundleFiles(layout.publicPath)
|
|
73
|
+
]);
|
|
74
|
+
const unzippedBytes = serverFiles.reduce((total, file)=>total + file.size, 0) + publicFiles.reduce((total, file)=>total + file.size, 0) + runShStat.size;
|
|
69
75
|
await createZip(zipPath, [
|
|
70
76
|
{
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
files: serverFiles,
|
|
78
|
+
prefix: serverPrefix
|
|
73
79
|
},
|
|
74
80
|
{
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
files: publicFiles,
|
|
82
|
+
prefix: publicPrefix
|
|
77
83
|
},
|
|
78
84
|
{
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
files: [
|
|
86
|
+
{
|
|
87
|
+
relativePath: 'run.sh',
|
|
88
|
+
size: runShStat.size,
|
|
89
|
+
sourcePath: runShPath
|
|
90
|
+
}
|
|
91
|
+
],
|
|
92
|
+
prefix: ''
|
|
81
93
|
}
|
|
82
94
|
]);
|
|
83
|
-
const runShStat = await fs.stat(runShPath);
|
|
84
|
-
const unzippedBytes = await getDirectorySize(serverDir) + await getDirectorySize(layout.publicPath) + runShStat.size;
|
|
85
95
|
return {
|
|
86
96
|
unzippedBytes,
|
|
87
97
|
zipPath
|
|
@@ -135,9 +145,22 @@ NODE_ENV=production exec node '${escapedEntry}'
|
|
|
135
145
|
}
|
|
136
146
|
return pages;
|
|
137
147
|
}
|
|
148
|
+
function zipPrefix(relativeDir) {
|
|
149
|
+
return relativeDir ? `${relativeDir}/` : '';
|
|
150
|
+
}
|
|
138
151
|
function toPageUploadKey(page) {
|
|
139
152
|
return page === 'index' ? 'index.html' : `${page}/index.html`;
|
|
140
153
|
}
|
|
154
|
+
async function walkPublicBundleFiles(publicPath) {
|
|
155
|
+
try {
|
|
156
|
+
return await walkBundleFiles(publicPath);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error instanceof EscapingSymlinkError) {
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
141
164
|
async function createZip(zipPath, entries) {
|
|
142
165
|
return new Promise((resolve, reject)=>{
|
|
143
166
|
const output = createWriteStream(zipPath);
|
|
@@ -150,17 +173,10 @@ async function createZip(zipPath, entries) {
|
|
|
150
173
|
archive.on('error', (err)=>reject(err));
|
|
151
174
|
archive.pipe(output);
|
|
152
175
|
for (const entry of entries){
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
} else {
|
|
158
|
-
archive.file(entry.source, {
|
|
159
|
-
name: entry.prefix + path.basename(entry.source)
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
} catch {
|
|
163
|
-
// Skip missing entries
|
|
176
|
+
for (const file of entry.files){
|
|
177
|
+
archive.file(file.sourcePath, {
|
|
178
|
+
name: entry.prefix + file.relativePath
|
|
179
|
+
});
|
|
164
180
|
}
|
|
165
181
|
}
|
|
166
182
|
void archive.finalize();
|
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import { resolveOutputPath } from '../deploy-output/resolveOutputPath.js';
|
|
3
3
|
import { collectFilesRecursive } from '../fs-utils.js';
|
|
4
|
+
/**
|
|
5
|
+
* A buildless static deploy points `--output` at the project root, so local
|
|
6
|
+
* secrets and private history sit beside the intended web assets. Every
|
|
7
|
+
* collected file becomes a publicly reachable route, so never publish these.
|
|
8
|
+
*/ const EXCLUDED_ASSET_NAMES = new Set([
|
|
9
|
+
'.git',
|
|
10
|
+
'.netrc',
|
|
11
|
+
'.npmrc',
|
|
12
|
+
'.ssh',
|
|
13
|
+
'node_modules'
|
|
14
|
+
]);
|
|
15
|
+
function isPublishableAsset(key) {
|
|
16
|
+
return key.split('/').every((segment)=>!EXCLUDED_ASSET_NAMES.has(segment) && !segment.startsWith('.env'));
|
|
17
|
+
}
|
|
4
18
|
export class ViteAdapter {
|
|
5
19
|
fallback = '/index.html';
|
|
6
20
|
name = 'vite';
|
|
7
21
|
async collectAssets(projectPath, output) {
|
|
8
22
|
const outputPath = resolveOutputPath(projectPath, 'vite', output);
|
|
9
|
-
const files = await collectFilesRecursive(outputPath, outputPath);
|
|
23
|
+
const files = (await collectFilesRecursive(outputPath, outputPath)).filter(isPublishableAsset);
|
|
10
24
|
const pathMap = Object.fromEntries(files.map((file)=>[
|
|
11
25
|
file,
|
|
12
26
|
path.relative(projectPath, path.join(outputPath, file))
|
|
@@ -30,7 +30,7 @@ type StaticMetadataAssets = Omit<SSGAssets, 'routeAssetMap'>;
|
|
|
30
30
|
/**
|
|
31
31
|
* Collect SSG asset paths by parsing .next/prerender-manifest.json
|
|
32
32
|
*
|
|
33
|
-
* For each page route in the manifest's `routes` object, collects:
|
|
33
|
+
* For each page route without revalidation in the manifest's `routes` object, collects:
|
|
34
34
|
* - {route}.html (full HTML)
|
|
35
35
|
* - {route}.rsc (React Server Components payload)
|
|
36
36
|
* - {route}.meta (response headers JSON)
|
|
@@ -77,7 +77,7 @@ const MAX_ASSET_SIZE = 50 * 1024 * 1024 // 50MB
|
|
|
77
77
|
/**
|
|
78
78
|
* Collect SSG asset paths by parsing .next/prerender-manifest.json
|
|
79
79
|
*
|
|
80
|
-
* For each page route in the manifest's `routes` object, collects:
|
|
80
|
+
* For each page route without revalidation in the manifest's `routes` object, collects:
|
|
81
81
|
* - {route}.html (full HTML)
|
|
82
82
|
* - {route}.rsc (React Server Components payload)
|
|
83
83
|
* - {route}.meta (response headers JSON)
|
|
@@ -112,11 +112,6 @@ const MAX_ASSET_SIZE = 50 * 1024 * 1024 // 50MB
|
|
|
112
112
|
const keys = [];
|
|
113
113
|
const pathMap = {};
|
|
114
114
|
const routeAssetMap = {};
|
|
115
|
-
const extensions = [
|
|
116
|
-
'.html',
|
|
117
|
-
'.rsc',
|
|
118
|
-
'.meta'
|
|
119
|
-
];
|
|
120
115
|
const appDir = path.join(outputPath, 'server', 'app');
|
|
121
116
|
for (const [routeKey, route] of Object.entries(manifest.routes)){
|
|
122
117
|
// Current manifests classify app pages explicitly. Older App Router manifests
|
|
@@ -125,9 +120,26 @@ const MAX_ASSET_SIZE = 50 * 1024 * 1024 // 50MB
|
|
|
125
120
|
if (!isPageRoute) {
|
|
126
121
|
continue;
|
|
127
122
|
}
|
|
123
|
+
// ISR prerenders are seeds for the Next.js runtime, not permanent S3 pages.
|
|
124
|
+
// Keep legacy manifests without this field; only false explicitly disables revalidation.
|
|
125
|
+
if (route.initialRevalidateSeconds !== undefined && route.initialRevalidateSeconds !== false) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
128
|
// Strip leading slash for S3 key; handle root "/" → "index"
|
|
129
129
|
const stripped = routeKey === '/' ? 'index' : routeKey.replace(/^\//, '');
|
|
130
130
|
const routeAssets = [];
|
|
131
|
+
// For partially static routes, dataRoute can name a runtime endpoint even
|
|
132
|
+
// when the build has no full RSC file. Next.js sets prefetchDataRoute only
|
|
133
|
+
// when it emits that static payload.
|
|
134
|
+
const hasFullRSC = route.renderingMode !== 'PARTIALLY_STATIC' || typeof route.prefetchDataRoute === 'string';
|
|
135
|
+
const extensions = hasFullRSC ? [
|
|
136
|
+
'.html',
|
|
137
|
+
'.rsc',
|
|
138
|
+
'.meta'
|
|
139
|
+
] : [
|
|
140
|
+
'.html',
|
|
141
|
+
'.meta'
|
|
142
|
+
];
|
|
131
143
|
for (const ext of extensions){
|
|
132
144
|
const s3Key = `${stripped}${ext}`;
|
|
133
145
|
keys.push(s3Key);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import archiver from 'archiver';
|
|
2
2
|
import { createWriteStream } from 'fs';
|
|
3
|
-
import fs from 'fs/promises';
|
|
4
3
|
import path from 'path';
|
|
4
|
+
import { configureNextjsCache } from './configureNextjsCache.js';
|
|
5
5
|
import { inspectNextjsOutput } from './deploy-output/inspectNextjsOutput.js';
|
|
6
6
|
import { resolveOutputPath } from './deploy-output/resolveOutputPath.js';
|
|
7
|
-
import {
|
|
7
|
+
import { walkBundleFiles } from './fs-utils.js';
|
|
8
8
|
/**
|
|
9
9
|
* AWS Lambda's hard limit on unzipped deployment code size (250 MB). The unzipped
|
|
10
10
|
* total must be strictly less than this value, so a bundle exactly equal to it is
|
|
@@ -31,17 +31,43 @@ import { getDirectorySize } from './fs-utils.js';
|
|
|
31
31
|
if (!layout) {
|
|
32
32
|
throw new Error(`Standalone build not found at ${outputPath}`);
|
|
33
33
|
}
|
|
34
|
-
const
|
|
35
|
-
|
|
34
|
+
const files = await walkBundleFiles(layout.standalonePath);
|
|
35
|
+
// Override archive entries in memory without changing Next's standalone output.
|
|
36
|
+
const bundleFileOverrides = await createBundleFileOverrides(layout.standalonePath, layout.serverEntryPath);
|
|
36
37
|
const zipPath = path.join(projectPath, 'lambda.zip');
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
let unzippedBytes = files.reduce((total, file)=>total + file.size, 0);
|
|
39
|
+
for (const [name, contents] of bundleFileOverrides){
|
|
40
|
+
const existingBytes = files.find((file)=>file.relativePath === name)?.size ?? 0;
|
|
41
|
+
unzippedBytes += contents.byteLength - existingBytes;
|
|
42
|
+
}
|
|
43
|
+
await createZip(files, zipPath, bundleFileOverrides);
|
|
40
44
|
return {
|
|
41
45
|
unzippedBytes,
|
|
42
46
|
zipPath
|
|
43
47
|
};
|
|
44
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Returns files that replace or extend the standalone output in the Lambda zip:
|
|
51
|
+
* the generated launcher, the cache-configured server entry, and its optional
|
|
52
|
+
* cache handler. All paths are relative to the zip root.
|
|
53
|
+
*/ async function createBundleFileOverrides(standalonePath, serverEntryPath) {
|
|
54
|
+
const serverEntry = path.relative(standalonePath, serverEntryPath).split(path.sep).join('/');
|
|
55
|
+
const cacheConfiguration = await configureNextjsCache(serverEntryPath);
|
|
56
|
+
const bundleFileOverrides = new Map([
|
|
57
|
+
[
|
|
58
|
+
'run.sh',
|
|
59
|
+
Buffer.from(createRunScript(serverEntry))
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
serverEntry,
|
|
63
|
+
Buffer.from(cacheConfiguration.server)
|
|
64
|
+
]
|
|
65
|
+
]);
|
|
66
|
+
if (cacheConfiguration.handler) {
|
|
67
|
+
bundleFileOverrides.set(path.posix.join(path.posix.dirname(serverEntry), cacheConfiguration.handler.name), cacheConfiguration.handler.data);
|
|
68
|
+
}
|
|
69
|
+
return bundleFileOverrides;
|
|
70
|
+
}
|
|
45
71
|
function createRunScript(serverEntry) {
|
|
46
72
|
const escapedEntry = serverEntry.replaceAll("'", "'\\''");
|
|
47
73
|
return `#!/bin/bash -x
|
|
@@ -49,10 +75,7 @@ function createRunScript(serverEntry) {
|
|
|
49
75
|
NODE_ENV=production exec node '${escapedEntry}'
|
|
50
76
|
`;
|
|
51
77
|
}
|
|
52
|
-
|
|
53
|
-
return fs.stat(filePath).then((stat)=>stat.isFile() ? stat.size : 0).catch(()=>0);
|
|
54
|
-
}
|
|
55
|
-
function createZip(sourceDir, zipPath, runScript) {
|
|
78
|
+
function createZip(files, zipPath, bundleFileOverrides) {
|
|
56
79
|
return new Promise((resolve, reject)=>{
|
|
57
80
|
const output = createWriteStream(zipPath);
|
|
58
81
|
const archive = archiver('zip', {
|
|
@@ -63,11 +86,20 @@ function createZip(sourceDir, zipPath, runScript) {
|
|
|
63
86
|
output.on('close', ()=>resolve());
|
|
64
87
|
archive.on('error', (err)=>reject(err));
|
|
65
88
|
archive.pipe(output);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
for (const file of files){
|
|
90
|
+
if (bundleFileOverrides.has(file.relativePath)) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
archive.file(file.sourcePath, {
|
|
94
|
+
name: file.relativePath
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
for (const [name, contents] of bundleFileOverrides){
|
|
98
|
+
archive.append(contents, {
|
|
99
|
+
name,
|
|
100
|
+
mode: name === 'run.sh' ? 0o755 : 0o644
|
|
101
|
+
});
|
|
102
|
+
}
|
|
71
103
|
void archive.finalize();
|
|
72
104
|
});
|
|
73
105
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { Node, Project } from 'ts-morph';
|
|
4
|
+
import { info } from './log.js';
|
|
5
|
+
const CACHE_HANDLER_FILE = '__figma_nextjs_cache.mjs';
|
|
6
|
+
export async function configureNextjsCache(serverEntryPath) {
|
|
7
|
+
const original = await fs.readFile(serverEntryPath, 'utf8');
|
|
8
|
+
const project = new Project({
|
|
9
|
+
useInMemoryFileSystem: true
|
|
10
|
+
});
|
|
11
|
+
const source = project.createSourceFile('server.js', original);
|
|
12
|
+
const initializer = source.getVariableDeclaration('nextConfig')?.getInitializer();
|
|
13
|
+
if (!initializer || !Node.isObjectLiteralExpression(initializer)) {
|
|
14
|
+
throw new Error('Unsupported Next.js standalone server: expected a nextConfig object');
|
|
15
|
+
}
|
|
16
|
+
// Only accept Next's generated JSON, not arbitrary application code.
|
|
17
|
+
let config;
|
|
18
|
+
try {
|
|
19
|
+
config = JSON.parse(initializer.getText());
|
|
20
|
+
} catch {
|
|
21
|
+
throw new Error('Unsupported Next.js standalone server: expected generated JSON config');
|
|
22
|
+
}
|
|
23
|
+
const experimental = config.experimental;
|
|
24
|
+
// cacheHandler is the stable name; older Next.js builds use the experimental path.
|
|
25
|
+
if (config.cacheHandler || experimental?.incrementalCacheHandlerPath) {
|
|
26
|
+
info('Using the application cacheHandler; Figma fresh-only caching is not enabled.');
|
|
27
|
+
return {
|
|
28
|
+
server: original
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const reservedPath = path.join(path.dirname(serverEntryPath), CACHE_HANDLER_FILE);
|
|
32
|
+
const exists = await fs.lstat(reservedPath).then(()=>true, (error)=>{
|
|
33
|
+
if (error.code === 'ENOENT') {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
});
|
|
38
|
+
if (exists) {
|
|
39
|
+
throw new Error(`Standalone output contains reserved file ${CACHE_HANDLER_FILE}`);
|
|
40
|
+
}
|
|
41
|
+
initializer.getProperty('"cacheHandler"')?.remove();
|
|
42
|
+
initializer.addPropertyAssignments([
|
|
43
|
+
{
|
|
44
|
+
name: 'cacheHandler',
|
|
45
|
+
initializer: `require.resolve('./${CACHE_HANDLER_FILE}')`
|
|
46
|
+
}
|
|
47
|
+
]);
|
|
48
|
+
return {
|
|
49
|
+
handler: {
|
|
50
|
+
name: CACHE_HANDLER_FILE,
|
|
51
|
+
data: await fs.readFile(new URL('./lambda-runtime/nextjs-cache.js', import.meta.url))
|
|
52
|
+
},
|
|
53
|
+
server: source.getFullText()
|
|
54
|
+
};
|
|
55
|
+
}
|
package/dist/utils/fs-utils.d.ts
CHANGED
|
@@ -3,10 +3,37 @@
|
|
|
3
3
|
* returned as forward-slash-separated paths relative to baseDir.
|
|
4
4
|
*/
|
|
5
5
|
export declare function collectFilesRecursive(dir: string, baseDir: string): Promise<string[]>;
|
|
6
|
+
export declare class EscapingSymlinkError extends Error {
|
|
7
|
+
readonly symlinkPath: string;
|
|
8
|
+
readonly targetPath: string;
|
|
9
|
+
constructor(symlinkPath: string, targetPath: string, root: string);
|
|
10
|
+
}
|
|
11
|
+
export interface BundleFile {
|
|
12
|
+
/** Forward-slash path relative to the walk root; used as the zip entry name. */
|
|
13
|
+
relativePath: string;
|
|
14
|
+
/**
|
|
15
|
+
* Bytes this entry contributes to unzipped size. Regular files use content
|
|
16
|
+
* size; in-tree symlinks use `lstat` size (the stored link path).
|
|
17
|
+
*/
|
|
18
|
+
size: number;
|
|
19
|
+
/** Path passed to the archiver; may be an in-tree symlink. */
|
|
20
|
+
sourcePath: string;
|
|
21
|
+
}
|
|
6
22
|
/**
|
|
7
|
-
* Recursively
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
23
|
+
* Recursively list files under `root` for Lambda bundle sizing and archiving.
|
|
24
|
+
*
|
|
25
|
+
* Regular files and directories are included. Symlinks are included (not
|
|
26
|
+
* followed) only when the resolved target stays within `root`; otherwise an
|
|
27
|
+
* {@link EscapingSymlinkError} is thrown. This matches `archiver.directory`,
|
|
28
|
+
* which stores zip symlink entries rather than copying target contents.
|
|
29
|
+
* Broken or unreadable files are skipped.
|
|
30
|
+
*/
|
|
31
|
+
export declare function walkBundleFiles(root: string): Promise<BundleFile[]>;
|
|
32
|
+
/**
|
|
33
|
+
* Recursively sum the byte size of all files under a directory using the same
|
|
34
|
+
* symlink policy as {@link walkBundleFiles}. Returns 0 for a missing or
|
|
35
|
+
* unreadable directory. Escaping symlinks throw {@link EscapingSymlinkError}.
|
|
36
|
+
* Files that disappear or become unreadable between readdir and stat are
|
|
37
|
+
* skipped (treated as 0 bytes).
|
|
11
38
|
*/
|
|
12
39
|
export declare function getDirectorySize(dir: string): Promise<number>;
|
package/dist/utils/fs-utils.js
CHANGED
|
@@ -23,33 +23,113 @@ import path from 'path';
|
|
|
23
23
|
}
|
|
24
24
|
return files;
|
|
25
25
|
}
|
|
26
|
+
export class EscapingSymlinkError extends Error {
|
|
27
|
+
symlinkPath;
|
|
28
|
+
targetPath;
|
|
29
|
+
constructor(symlinkPath, targetPath, root){
|
|
30
|
+
super(`Symlink '${symlinkPath}' points outside ${root} (${targetPath})`), this.symlinkPath = symlinkPath, this.targetPath = targetPath;
|
|
31
|
+
this.name = 'EscapingSymlinkError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Recursively list files under `root` for Lambda bundle sizing and archiving.
|
|
36
|
+
*
|
|
37
|
+
* Regular files and directories are included. Symlinks are included (not
|
|
38
|
+
* followed) only when the resolved target stays within `root`; otherwise an
|
|
39
|
+
* {@link EscapingSymlinkError} is thrown. This matches `archiver.directory`,
|
|
40
|
+
* which stores zip symlink entries rather than copying target contents.
|
|
41
|
+
* Broken or unreadable files are skipped.
|
|
42
|
+
*/ export async function walkBundleFiles(root) {
|
|
43
|
+
const rootReal = await fs.realpath(root);
|
|
44
|
+
const files = [];
|
|
45
|
+
await walkBundleDir(root, root, rootReal, files);
|
|
46
|
+
return files;
|
|
47
|
+
}
|
|
26
48
|
/**
|
|
27
|
-
* Recursively sum the byte size of all files under a directory
|
|
28
|
-
* Returns 0 for a missing or
|
|
29
|
-
*
|
|
30
|
-
*
|
|
49
|
+
* Recursively sum the byte size of all files under a directory using the same
|
|
50
|
+
* symlink policy as {@link walkBundleFiles}. Returns 0 for a missing or
|
|
51
|
+
* unreadable directory. Escaping symlinks throw {@link EscapingSymlinkError}.
|
|
52
|
+
* Files that disappear or become unreadable between readdir and stat are
|
|
53
|
+
* skipped (treated as 0 bytes).
|
|
31
54
|
*/ export async function getDirectorySize(dir) {
|
|
55
|
+
try {
|
|
56
|
+
const files = await walkBundleFiles(dir);
|
|
57
|
+
return files.reduce((total, file)=>total + file.size, 0);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error instanceof EscapingSymlinkError) {
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function isPathWithin(rootReal, targetReal) {
|
|
66
|
+
const relative = path.relative(rootReal, targetReal);
|
|
67
|
+
return relative === '' || relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
68
|
+
}
|
|
69
|
+
async function walkBundleDir(dir, root, rootReal, files) {
|
|
32
70
|
let entries;
|
|
33
71
|
try {
|
|
34
72
|
entries = await fs.readdir(dir, {
|
|
35
73
|
withFileTypes: true
|
|
36
74
|
});
|
|
37
75
|
} catch {
|
|
38
|
-
return
|
|
76
|
+
return;
|
|
39
77
|
}
|
|
40
|
-
let total = 0;
|
|
41
78
|
for (const entry of entries){
|
|
42
79
|
const fullPath = path.join(dir, entry.name);
|
|
43
|
-
if (entry.
|
|
44
|
-
|
|
80
|
+
if (entry.isSymbolicLink()) {
|
|
81
|
+
await addSymlinkEntry(fullPath, root, rootReal, files);
|
|
82
|
+
} else if (entry.isDirectory()) {
|
|
83
|
+
await walkBundleDir(fullPath, root, rootReal, files);
|
|
45
84
|
} else if (entry.isFile()) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
85
|
+
await addRegularFile(fullPath, root, files);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function addSymlinkEntry(fullPath, root, rootReal, files) {
|
|
90
|
+
const relativePath = toRelativePath(root, fullPath);
|
|
91
|
+
const targetPath = await resolveSymlinkTarget(fullPath);
|
|
92
|
+
if (targetPath === null) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!isPathWithin(rootReal, targetPath)) {
|
|
96
|
+
throw new EscapingSymlinkError(relativePath, targetPath, root);
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const stats = await fs.lstat(fullPath);
|
|
100
|
+
files.push({
|
|
101
|
+
relativePath,
|
|
102
|
+
size: stats.size,
|
|
103
|
+
sourcePath: fullPath
|
|
104
|
+
});
|
|
105
|
+
} catch {
|
|
106
|
+
// Symlink vanished between readdir and lstat — skip it.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function resolveSymlinkTarget(fullPath) {
|
|
110
|
+
try {
|
|
111
|
+
return await fs.realpath(fullPath);
|
|
112
|
+
} catch {
|
|
113
|
+
try {
|
|
114
|
+
const link = await fs.readlink(fullPath);
|
|
115
|
+
return path.resolve(path.dirname(fullPath), link);
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
52
118
|
}
|
|
53
119
|
}
|
|
54
|
-
|
|
120
|
+
}
|
|
121
|
+
async function addRegularFile(fullPath, root, files) {
|
|
122
|
+
try {
|
|
123
|
+
const stats = await fs.stat(fullPath);
|
|
124
|
+
files.push({
|
|
125
|
+
relativePath: toRelativePath(root, fullPath),
|
|
126
|
+
size: stats.size,
|
|
127
|
+
sourcePath: fullPath
|
|
128
|
+
});
|
|
129
|
+
} catch {
|
|
130
|
+
// File vanished or is unreadable between readdir and stat — skip it.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function toRelativePath(root, fullPath) {
|
|
134
|
+
return path.relative(root, fullPath).split(path.sep).join('/');
|
|
55
135
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A process-local, fresh-only Next.js cacheHandler. The CDN owns stale serving.
|
|
3
|
+
* No filesystem access: Lambda's code directory is read-only. Each cold process
|
|
4
|
+
* starts empty; Gatekeeper serves the separately uploaded static assets.
|
|
5
|
+
*/
|
|
6
|
+
export default class NextjsCacheHandler {
|
|
7
|
+
tagVersion: number;
|
|
8
|
+
get(key: any, context?: {}): Promise<{
|
|
9
|
+
value: any;
|
|
10
|
+
lastModified: any;
|
|
11
|
+
} | null>;
|
|
12
|
+
set(key: any, value: any, context?: {}): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* The revalidateTag function allows invalidating cached data on-demand based
|
|
15
|
+
* on the specified tags. For our purposes, since we are currently doing a
|
|
16
|
+
* local in-memory, per-process lambda cache, we can just go ahead and clear
|
|
17
|
+
* the cache. Eventually we will want to support doing tag invalidation at the
|
|
18
|
+
* CDN layer.
|
|
19
|
+
*/
|
|
20
|
+
revalidateTag(tags: any, _durations?: {}): Promise<void>;
|
|
21
|
+
resetRequestCache(): void;
|
|
22
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { deserialize, serialize } from 'node:v8';
|
|
2
|
+
const MAX_CACHE_BYTES = 8 * 1024 * 1024;
|
|
3
|
+
// Approximate Map entry and metadata overhead; actual heap use can differ.
|
|
4
|
+
const ESTIMATED_ENTRY_OVERHEAD_BYTES = 64;
|
|
5
|
+
const cache = new Map();
|
|
6
|
+
let cacheBytes = 0;
|
|
7
|
+
// Tag invalidation advances this counter to reject writes from earlier requests.
|
|
8
|
+
let tagVersion = 0;
|
|
9
|
+
/**
|
|
10
|
+
* A process-local, fresh-only Next.js cacheHandler. The CDN owns stale serving.
|
|
11
|
+
* No filesystem access: Lambda's code directory is read-only. Each cold process
|
|
12
|
+
* starts empty; Gatekeeper serves the separately uploaded static assets.
|
|
13
|
+
*/ export default class NextjsCacheHandler {
|
|
14
|
+
constructor(){
|
|
15
|
+
this.tagVersion = tagVersion;
|
|
16
|
+
}
|
|
17
|
+
async get(key, context = {}) {
|
|
18
|
+
const cacheKey = getCacheKey(key, context.kind === 'FETCH');
|
|
19
|
+
const entry = cache.get(cacheKey);
|
|
20
|
+
if (!entry) {
|
|
21
|
+
observe('miss', context.kind);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const effectiveTTL = context.kind === 'FETCH' && context.revalidate !== undefined ? Math.min(entry.seconds, lifetime(context.revalidate)) : entry.seconds;
|
|
25
|
+
const ageMs = Math.max(0, Date.now() - entry.lastModified);
|
|
26
|
+
if (ageMs >= effectiveTTL * 1000) {
|
|
27
|
+
if (ageMs >= entry.seconds * 1000) {
|
|
28
|
+
remove(cacheKey);
|
|
29
|
+
}
|
|
30
|
+
observe('expired', context.kind);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
// Clone cached buffers/maps so a caller cannot mutate the stored value.
|
|
34
|
+
// V8 can return Buffer views into its input, so copy the serialized bytes.
|
|
35
|
+
const value = deserialize(Buffer.from(entry.data));
|
|
36
|
+
if (value && [
|
|
37
|
+
'APP_PAGE',
|
|
38
|
+
'APP_ROUTE',
|
|
39
|
+
'PAGES'
|
|
40
|
+
].includes(value.kind)) {
|
|
41
|
+
value.headers = withoutAge(value.headers);
|
|
42
|
+
value.headers.Age = String(Math.floor(ageMs / 1000));
|
|
43
|
+
}
|
|
44
|
+
cache.delete(cacheKey);
|
|
45
|
+
cache.set(cacheKey, entry);
|
|
46
|
+
observe('hit', context.kind);
|
|
47
|
+
return {
|
|
48
|
+
value,
|
|
49
|
+
lastModified: entry.lastModified
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async set(key, value, context = {}) {
|
|
53
|
+
// A render that overlaps invalidation must not restore an old value.
|
|
54
|
+
if (this.tagVersion !== tagVersion) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const cacheKey = getCacheKey(key, context.fetchCache);
|
|
58
|
+
remove(cacheKey);
|
|
59
|
+
const seconds = lifetime(context.fetchCache ? value?.revalidate : context.cacheControl?.revalidate);
|
|
60
|
+
if (seconds === 0) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
let data;
|
|
64
|
+
try {
|
|
65
|
+
data = serialize(value);
|
|
66
|
+
} catch {
|
|
67
|
+
observe('unsupported', value?.kind);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const size = data.byteLength + Buffer.byteLength(cacheKey) + ESTIMATED_ENTRY_OVERHEAD_BYTES;
|
|
71
|
+
if (size > MAX_CACHE_BYTES) {
|
|
72
|
+
observe('oversized', value?.kind);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// Preserve an upstream Age, including a previous cached response that Next
|
|
76
|
+
// attempts to write again after an unsuccessful on-demand revalidation.
|
|
77
|
+
const age = responseAge(value);
|
|
78
|
+
if (age >= seconds) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
while(cacheBytes + size > MAX_CACHE_BYTES){
|
|
82
|
+
remove(cache.keys().next().value);
|
|
83
|
+
}
|
|
84
|
+
cache.set(cacheKey, {
|
|
85
|
+
data,
|
|
86
|
+
size,
|
|
87
|
+
seconds,
|
|
88
|
+
lastModified: Date.now() - age * 1000
|
|
89
|
+
});
|
|
90
|
+
cacheBytes += size;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The revalidateTag function allows invalidating cached data on-demand based
|
|
94
|
+
* on the specified tags. For our purposes, since we are currently doing a
|
|
95
|
+
* local in-memory, per-process lambda cache, we can just go ahead and clear
|
|
96
|
+
* the cache. Eventually we will want to support doing tag invalidation at the
|
|
97
|
+
* CDN layer.
|
|
98
|
+
*/ async revalidateTag(tags, _durations = {}) {
|
|
99
|
+
const tagsArr = Array.isArray(tags) ? tags : [
|
|
100
|
+
tags
|
|
101
|
+
];
|
|
102
|
+
const hasValidTag = tagsArr.some((tag)=>typeof tag === 'string' && tag.length);
|
|
103
|
+
if (!hasValidTag) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
cache.clear();
|
|
107
|
+
cacheBytes = 0;
|
|
108
|
+
tagVersion += 1;
|
|
109
|
+
observe('invalidated', 'local');
|
|
110
|
+
}
|
|
111
|
+
// Outstanding writes still belong to the request that started before invalidation.
|
|
112
|
+
resetRequestCache() {}
|
|
113
|
+
}
|
|
114
|
+
function getCacheKey(key, fetchCache) {
|
|
115
|
+
return `${fetchCache ? 'fetch' : 'route'}:${key}`;
|
|
116
|
+
}
|
|
117
|
+
function lifetime(revalidate) {
|
|
118
|
+
if (revalidate === false) {
|
|
119
|
+
return Infinity;
|
|
120
|
+
}
|
|
121
|
+
return Number.isSafeInteger(revalidate) && revalidate > 0 ? revalidate : 0;
|
|
122
|
+
}
|
|
123
|
+
function remove(key) {
|
|
124
|
+
const entry = cache.get(key);
|
|
125
|
+
if (entry) {
|
|
126
|
+
cacheBytes -= entry.size;
|
|
127
|
+
}
|
|
128
|
+
cache.delete(key);
|
|
129
|
+
}
|
|
130
|
+
function withoutAge(headers) {
|
|
131
|
+
return Object.fromEntries(Object.entries(headers ?? {}).filter(([name])=>name.toLowerCase() !== 'age'));
|
|
132
|
+
}
|
|
133
|
+
function responseAge(value) {
|
|
134
|
+
if (!value || value.kind === 'FETCH') {
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
const age = Object.entries(value.headers ?? {}).find(([name])=>name.toLowerCase() === 'age')?.[1];
|
|
138
|
+
if (age === undefined) {
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
if (typeof age !== 'number' && (typeof age !== 'string' || !/^\d+$/.test(age))) {
|
|
142
|
+
return Infinity;
|
|
143
|
+
}
|
|
144
|
+
const seconds = Number(age);
|
|
145
|
+
return Number.isSafeInteger(seconds) && seconds >= 0 ? seconds : Infinity;
|
|
146
|
+
}
|
|
147
|
+
function observe(result, kind) {
|
|
148
|
+
if (![
|
|
149
|
+
'APP_PAGE',
|
|
150
|
+
'APP_ROUTE',
|
|
151
|
+
'PAGES',
|
|
152
|
+
'local'
|
|
153
|
+
].includes(kind)) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// Bounded fields only: never log cache keys, URLs, values, or tags.
|
|
157
|
+
console.info(JSON.stringify({
|
|
158
|
+
event: 'figma_nextjs_cache',
|
|
159
|
+
result,
|
|
160
|
+
kind
|
|
161
|
+
}));
|
|
162
|
+
}
|