@payloadcms/figma 0.1.0-alpha.6 → 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.
Files changed (38) hide show
  1. package/LICENSE.md +1 -1
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +2 -0
  4. package/dist/oauth/components/LoginButton/index.js +66 -19
  5. package/dist/oauth/components/LoginButton/index.scss +121 -15
  6. package/dist/plugin/build-config.d.ts +4 -2
  7. package/dist/plugin/build-config.js +34 -22
  8. package/dist/plugin/make-permission-access/make-permission-access.d.ts +8 -0
  9. package/dist/plugin/make-permission-access/make-permission-access.js +24 -0
  10. package/dist/storage-content-api/client-uploads/generate.js +2 -1
  11. package/dist/storage-content-api/index.js +1 -0
  12. package/dist/storage-content-api/staticHandler.js +21 -82
  13. package/dist/storage-content-api/types.d.ts +4 -1
  14. package/dist/utils/adapters/nextjs.js +5 -13
  15. package/dist/utils/adapters/nitro.js +39 -23
  16. package/dist/utils/adapters/vite.js +15 -1
  17. package/dist/utils/asset-collection.d.ts +7 -2
  18. package/dist/utils/asset-collection.js +44 -10
  19. package/dist/utils/build-lambda-zip.js +48 -16
  20. package/dist/utils/configureNextjsCache.d.ts +7 -0
  21. package/dist/utils/configureNextjsCache.js +55 -0
  22. package/dist/utils/fs-utils.d.ts +31 -4
  23. package/dist/utils/fs-utils.js +95 -15
  24. package/dist/utils/lambda-runtime/nextjs-cache.d.ts +22 -0
  25. package/dist/utils/lambda-runtime/nextjs-cache.js +162 -0
  26. package/package.json +12 -7
  27. package/dist/storage-content-api/buildBaseMediaResponseHeaders.d.ts +0 -1
  28. package/dist/storage-content-api/buildBaseMediaResponseHeaders.js +0 -6
  29. package/dist/storage-content-api/buildClientUploadFetchBackHeaders.d.ts +0 -10
  30. package/dist/storage-content-api/buildClientUploadFetchBackHeaders.js +0 -21
  31. package/dist/storage-content-api/buildMediaResponseHeaders.d.ts +0 -4
  32. package/dist/storage-content-api/buildMediaResponseHeaders.js +0 -45
  33. package/dist/storage-content-api/getInlineContentType.d.ts +0 -1
  34. package/dist/storage-content-api/getInlineContentType.js +0 -33
  35. package/dist/storage-content-api/hasSupportedContentEncoding.d.ts +0 -1
  36. package/dist/storage-content-api/hasSupportedContentEncoding.js +0 -4
  37. package/dist/storage-content-api/mediaErrorResponse.d.ts +0 -1
  38. package/dist/storage-content-api/mediaErrorResponse.js +0 -12
@@ -1,10 +1,10 @@
1
1
  import archiver from 'archiver';
2
- import { createWriteStream, statSync } from 'fs';
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, getDirectorySize } from '../fs-utils.js';
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
- prefix: `${serverPrefix}/`,
72
- source: serverDir
77
+ files: serverFiles,
78
+ prefix: serverPrefix
73
79
  },
74
80
  {
75
- prefix: `${publicPrefix}/`,
76
- source: layout.publicPath
81
+ files: publicFiles,
82
+ prefix: publicPrefix
77
83
  },
78
84
  {
79
- prefix: '',
80
- source: runShPath
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
- try {
154
- const stat = statSync(entry.source);
155
- if (stat.isDirectory()) {
156
- archive.directory(entry.source, entry.prefix.replace(/\/$/, '') || false);
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))
@@ -23,14 +23,18 @@ export type SSGAssets = {
23
23
  keys: string[];
24
24
  /** Map of S3 key → relative filesystem path from project root */
25
25
  pathMap: Record<string, string>;
26
+ /** Map of page route → S3 keys generated for that page */
27
+ routeAssetMap: Record<string, string[]>;
26
28
  };
29
+ type StaticMetadataAssets = Omit<SSGAssets, 'routeAssetMap'>;
27
30
  /**
28
31
  * Collect SSG asset paths by parsing .next/prerender-manifest.json
29
32
  *
30
- * For each page route in the manifest's `routes` object, collects three files:
33
+ * For each page route without revalidation in the manifest's `routes` object, collects:
31
34
  * - {route}.html (full HTML)
32
35
  * - {route}.rsc (React Server Components payload)
33
36
  * - {route}.meta (response headers JSON)
37
+ * - Nested `.segment.rsc` files under {route}.segments (segment-prefetch payloads)
34
38
  *
35
39
  * S3 keys use the route path without leading slash.
36
40
  * Filesystem paths point to .next/server/app/{route}.{ext}
@@ -46,7 +50,7 @@ export declare function collectSSGAssets(projectPath: string, output?: string):
46
50
  * `.next/server/app/shop/icon1.png.body` output.
47
51
  * Documentation: https://nextjs.org/docs/app/api-reference/file-conventions/metadata/app-icons
48
52
  */
49
- export declare function collectStaticMetadataAssets(projectPath: string, output?: string): Promise<SSGAssets>;
53
+ export declare function collectStaticMetadataAssets(projectPath: string, output?: string): Promise<StaticMetadataAssets>;
50
54
  /**
51
55
  * Create Lambda deployment zip
52
56
  *
@@ -56,3 +60,4 @@ export declare function collectStaticMetadataAssets(projectPath: string, output?
56
60
  * @throws Error if build missing or zip creation fails
57
61
  */
58
62
  export declare function createLambdaZip(projectPath: string): Promise<void>;
63
+ export {};
@@ -77,10 +77,11 @@ 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 three files:
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)
84
+ * - Nested `.segment.rsc` files under {route}.segments (segment-prefetch payloads)
84
85
  *
85
86
  * S3 keys use the route path without leading slash.
86
87
  * Filesystem paths point to .next/server/app/{route}.{ext}
@@ -97,22 +98,21 @@ const MAX_ASSET_SIZE = 50 * 1024 * 1024 // 50MB
97
98
  } catch {
98
99
  return {
99
100
  keys: [],
100
- pathMap: {}
101
+ pathMap: {},
102
+ routeAssetMap: {}
101
103
  };
102
104
  }
103
105
  if (!manifest.routes || typeof manifest.routes !== 'object') {
104
106
  return {
105
107
  keys: [],
106
- pathMap: {}
108
+ pathMap: {},
109
+ routeAssetMap: {}
107
110
  };
108
111
  }
109
112
  const keys = [];
110
113
  const pathMap = {};
111
- const extensions = [
112
- '.html',
113
- '.rsc',
114
- '.meta'
115
- ];
114
+ const routeAssetMap = {};
115
+ const appDir = path.join(outputPath, 'server', 'app');
116
116
  for (const [routeKey, route] of Object.entries(manifest.routes)){
117
117
  // Current manifests classify app pages explicitly. Older App Router manifests
118
118
  // can be identified by their RSC data route. Other entries may be route handlers.
@@ -120,17 +120,51 @@ const MAX_ASSET_SIZE = 50 * 1024 * 1024 // 50MB
120
120
  if (!isPageRoute) {
121
121
  continue;
122
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
+ }
123
128
  // Strip leading slash for S3 key; handle root "/" → "index"
124
129
  const stripped = routeKey === '/' ? 'index' : routeKey.replace(/^\//, '');
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
+ ];
125
143
  for (const ext of extensions){
126
144
  const s3Key = `${stripped}${ext}`;
127
145
  keys.push(s3Key);
128
- pathMap[s3Key] = path.relative(projectPath, path.join(outputPath, 'server', 'app', `${stripped}${ext}`));
146
+ routeAssets.push(s3Key);
147
+ pathMap[s3Key] = path.relative(projectPath, path.join(appDir, s3Key));
129
148
  }
149
+ // Next.js stores segment-prefetch payloads beside each prerendered page in a
150
+ // `<page>.segments` tree. Scan the generated tree instead of reconstructing
151
+ // its opaque, potentially nested segment names from route information.
152
+ const segmentFiles = await collectFiles(path.join(appDir, `${stripped}.segments`), appDir);
153
+ for (const segmentFile of segmentFiles.filter((file)=>file.endsWith('.segment.rsc'))){
154
+ const s3Key = segmentFile.split(path.sep).join('/');
155
+ keys.push(s3Key);
156
+ routeAssets.push(s3Key);
157
+ pathMap[s3Key] = path.relative(projectPath, path.join(appDir, segmentFile));
158
+ }
159
+ // Preserve the manifest route association for the original HTML/RSC/meta
160
+ // files and the new segment files; segment keys cannot be mapped back to a
161
+ // page reliably from their filename in NextjsAdapter.
162
+ routeAssetMap[routeKey] = routeAssets;
130
163
  }
131
164
  return {
132
165
  keys,
133
- pathMap
166
+ pathMap,
167
+ routeAssetMap
134
168
  };
135
169
  }
136
170
  /**
@@ -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 { getDirectorySize } from './fs-utils.js';
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 serverEntry = path.relative(layout.standalonePath, layout.serverEntryPath).split(path.sep).join('/');
35
- const runScript = createRunScript(serverEntry);
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
- const existingRunScriptBytes = await getFileSize(path.join(layout.standalonePath, 'run.sh'));
38
- const unzippedBytes = await getDirectorySize(layout.standalonePath) - existingRunScriptBytes + Buffer.byteLength(runScript);
39
- await createZip(layout.standalonePath, zipPath, runScript);
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
- async function getFileSize(filePath) {
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
- archive.directory(sourceDir, false, (entry)=>entry.name === 'run.sh' ? false : entry);
67
- archive.append(runScript, {
68
- name: 'run.sh',
69
- mode: 0o755
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,7 @@
1
+ export declare function configureNextjsCache(serverEntryPath: string): Promise<{
2
+ handler?: {
3
+ data: Buffer;
4
+ name: string;
5
+ };
6
+ server: string;
7
+ }>;
@@ -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
+ }
@@ -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 sum the byte size of all files under a directory.
8
- * Returns 0 for a missing or unreadable directory. Symlinks are not
9
- * followed. Files that disappear or become unreadable between readdir
10
- * and stat are skipped (treated as 0 bytes).
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>;
@@ -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 unreadable directory. Symlinks are not
29
- * followed. Files that disappear or become unreadable between readdir
30
- * and stat are skipped (treated as 0 bytes).
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 0;
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.isDirectory()) {
44
- total += await getDirectorySize(fullPath);
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
- try {
47
- const stats = await fs.stat(fullPath);
48
- total += stats.size;
49
- } catch {
50
- // File vanished or is unreadable between readdir and stat — skip it.
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
- return total;
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
+ }