@payloadcms/figma 0.1.0-alpha.4 → 0.1.0-alpha.6
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/db-content-api/index.js +31 -17
- package/dist/db-content-api/utilities/joins.js +5 -1
- package/dist/db-content-api/utilities/meta/resolvesToRelationshipValue.d.ts +10 -0
- package/dist/db-content-api/utilities/meta/resolvesToRelationshipValue.js +95 -0
- package/dist/db-content-api/utilities/meta/stringifyIdsInWhere.d.ts +9 -0
- package/dist/db-content-api/utilities/meta/stringifyIdsInWhere.js +112 -0
- package/dist/db-content-api/utilities/where.d.ts +2 -6
- package/dist/db-content-api/utilities/where.js +20 -11
- package/dist/exports/views.d.ts +1 -0
- package/dist/exports/views.js +1 -0
- package/dist/imageSizeOptions.d.ts +23 -0
- package/dist/imageSizeOptions.js +26 -0
- package/dist/index.d.ts +1 -0
- package/dist/oauth/endpoints/getLoginEndpoint.js +5 -2
- package/dist/oauth/index.js +8 -7
- package/dist/oauth/utilities/getAdminCollectionSlug.d.ts +1 -1
- package/dist/oauth/utilities/getAuthorizeURL.d.ts +1 -0
- package/dist/oauth/utilities/getAuthorizeURL.js +5 -1
- package/dist/plugin/build-config.js +22 -9
- package/dist/plugin/cloud-limits/CloudLimitModal/index.d.ts +2 -3
- package/dist/plugin/cloud-limits/CloudLimitModal/index.js +4 -16
- package/dist/plugin/cloud-limits/CloudLimitModal/index.scss +5 -0
- package/dist/plugin/cloud-limits/CloudLimitProvider/index.d.ts +0 -3
- package/dist/plugin/cloud-limits/CloudLimitProvider/index.js +65 -124
- package/dist/plugin/cloud-limits/LimitReachedView/index.css +14 -0
- package/dist/plugin/cloud-limits/LimitReachedView/index.d.ts +4 -0
- package/dist/plugin/cloud-limits/LimitReachedView/index.js +52 -0
- package/dist/plugin/cloud-limits/SlugRegistryContext/index.d.ts +7 -2
- package/dist/plugin/cloud-limits/SlugTracker/index.d.ts +0 -8
- package/dist/plugin/cloud-limits/SlugTracker/index.js +14 -10
- package/dist/plugin/cloud-limits/routes.d.ts +2 -0
- package/dist/plugin/cloud-limits/routes.js +4 -0
- package/dist/plugin/cloud-limits/server/redirectInitialAutosaveCreate.d.ts +9 -0
- package/dist/plugin/cloud-limits/server/redirectInitialAutosaveCreate.js +28 -0
- package/dist/plugin/cloud-limits/shared.d.ts +1 -1
- package/dist/plugin/cloud-limits/shared.js +25 -0
- package/dist/plugin/cloud-limits/utilities/cloudLimitInterceptor.d.ts +2 -0
- package/dist/plugin/cloud-limits/utilities/cloudLimitInterceptor.js +134 -0
- package/dist/plugin/cloud-limits/utilities/toasts.d.ts +4 -0
- package/dist/plugin/cloud-limits/utilities/toasts.js +19 -0
- package/dist/storage-content-api/buildBaseMediaResponseHeaders.d.ts +1 -0
- package/dist/storage-content-api/buildBaseMediaResponseHeaders.js +6 -0
- package/dist/storage-content-api/buildClientUploadFetchBackHeaders.d.ts +10 -0
- package/dist/storage-content-api/buildClientUploadFetchBackHeaders.js +21 -0
- package/dist/storage-content-api/buildMediaResponseHeaders.d.ts +4 -0
- package/dist/storage-content-api/buildMediaResponseHeaders.js +45 -0
- package/dist/storage-content-api/getInlineContentType.d.ts +1 -0
- package/dist/storage-content-api/getInlineContentType.js +33 -0
- package/dist/storage-content-api/hasSupportedContentEncoding.d.ts +1 -0
- package/dist/storage-content-api/hasSupportedContentEncoding.js +4 -0
- package/dist/storage-content-api/mediaErrorResponse.d.ts +1 -0
- package/dist/storage-content-api/mediaErrorResponse.js +12 -0
- package/dist/storage-content-api/staticHandler.js +82 -21
- package/dist/utils/adapters/nextjs.js +8 -7
- package/dist/utils/asset-collection.d.ts +9 -1
- package/dist/utils/asset-collection.js +64 -2
- package/dist/utils/s3-upload.js +4 -1
- package/package.json +14 -2
- package/dist/db-content-api/utilities/data/stringifyNumericIds.d.ts +0 -1
- package/dist/db-content-api/utilities/data/stringifyNumericIds.js +0 -19
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { toast } from '@payloadcms/ui';
|
|
3
|
+
import { formatAdminURL } from 'payload/shared';
|
|
4
|
+
import { getCloudLimitErrorCode } from '../shared.js';
|
|
5
|
+
import { showCloudLimitToast } from './toasts.js';
|
|
6
|
+
const ERROR_SUPPRESSION_MS = 1_000;
|
|
7
|
+
const SUMMARY_SUPPRESSION_MS = 60_000;
|
|
8
|
+
function restoreBulkErrorCodes(response, json) {
|
|
9
|
+
if (!json || typeof json !== 'object' || !Array.isArray(json.docs) || !Array.isArray(json.errors)) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
let hasLimitError = false;
|
|
13
|
+
const errors = json.errors.map((error)=>{
|
|
14
|
+
const code = getCloudLimitErrorCode({
|
|
15
|
+
errors: [
|
|
16
|
+
error
|
|
17
|
+
]
|
|
18
|
+
});
|
|
19
|
+
if (!code || !error || typeof error !== 'object') {
|
|
20
|
+
return error;
|
|
21
|
+
}
|
|
22
|
+
hasLimitError = true;
|
|
23
|
+
const data = error.data;
|
|
24
|
+
return {
|
|
25
|
+
...error,
|
|
26
|
+
data: {
|
|
27
|
+
...typeof data === 'object' && data ? data : {},
|
|
28
|
+
code
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
if (!hasLimitError) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const { message: _aggregateMessage, ...result } = json;
|
|
36
|
+
const headers = new Headers(response.headers);
|
|
37
|
+
headers.delete('content-encoding');
|
|
38
|
+
headers.delete('content-length');
|
|
39
|
+
return new Response(JSON.stringify({
|
|
40
|
+
...result,
|
|
41
|
+
errors
|
|
42
|
+
}), {
|
|
43
|
+
headers,
|
|
44
|
+
status: response.status,
|
|
45
|
+
statusText: response.statusText
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export function cloudLimitInterceptor(apiRoute, translate) {
|
|
49
|
+
const originalFetch = window.fetch;
|
|
50
|
+
const originalToastError = toast.error;
|
|
51
|
+
const suppressions = new Map();
|
|
52
|
+
let suppressSummary = false;
|
|
53
|
+
let summaryTimeout;
|
|
54
|
+
const patchedToastError = (...args)=>{
|
|
55
|
+
const message = typeof args[0] === 'string' ? args[0] : args[0]?.props?.errorMessage ?? '';
|
|
56
|
+
const match = [
|
|
57
|
+
...suppressions
|
|
58
|
+
].find(([rawMessage])=>message.includes(rawMessage));
|
|
59
|
+
if (match) {
|
|
60
|
+
const [, suppression] = match;
|
|
61
|
+
// Keep the suppression for its full lifetime so overlapping requests with the same raw
|
|
62
|
+
// error cannot leak duplicate Payload error toasts. The stable info-toast ID deduplicates
|
|
63
|
+
// the replacement notification.
|
|
64
|
+
showCloudLimitToast(suppression.code, translate);
|
|
65
|
+
if (suppression.isBulkUpload) {
|
|
66
|
+
suppressSummary = true;
|
|
67
|
+
window.clearTimeout(summaryTimeout);
|
|
68
|
+
summaryTimeout = window.setTimeout(()=>suppressSummary = false, SUMMARY_SUPPRESSION_MS);
|
|
69
|
+
}
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
if (suppressSummary && /^Failed to save \d+ files$/.test(message)) {
|
|
73
|
+
suppressSummary = false;
|
|
74
|
+
window.clearTimeout(summaryTimeout);
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
return originalToastError(...args);
|
|
78
|
+
};
|
|
79
|
+
const patchedFetch = async (...args)=>{
|
|
80
|
+
const response = await originalFetch(...args);
|
|
81
|
+
if (response.status < 400) {
|
|
82
|
+
return response;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const json = await response.clone().json();
|
|
86
|
+
const bulkResponse = response.status === 400 ? restoreBulkErrorCodes(response, json) : undefined;
|
|
87
|
+
if (bulkResponse) {
|
|
88
|
+
return bulkResponse;
|
|
89
|
+
}
|
|
90
|
+
const input = args[0];
|
|
91
|
+
const pathname = new URL(input instanceof Request ? input.url : String(input), window.location.origin).pathname;
|
|
92
|
+
const method = (input instanceof Request ? input.method : args[1]?.method ?? 'GET').toUpperCase();
|
|
93
|
+
const apiPath = formatAdminURL({
|
|
94
|
+
apiRoute,
|
|
95
|
+
path: ''
|
|
96
|
+
}).replace(/\/$/, '');
|
|
97
|
+
const endpoint = pathname.startsWith(`${apiPath}/`) ? pathname.slice(apiPath.length + 1) : '';
|
|
98
|
+
const isUploadInstructions = endpoint === 'upload-instructions';
|
|
99
|
+
const isCollectionCreate = method === 'POST' && endpoint !== '' && !endpoint.includes('/');
|
|
100
|
+
if (!isUploadInstructions && !isCollectionCreate) {
|
|
101
|
+
return response;
|
|
102
|
+
}
|
|
103
|
+
const code = getCloudLimitErrorCode(json);
|
|
104
|
+
const rawMessage = json?.errors?.[0]?.message;
|
|
105
|
+
if (code && rawMessage) {
|
|
106
|
+
const previous = suppressions.get(rawMessage);
|
|
107
|
+
window.clearTimeout(previous?.timeout);
|
|
108
|
+
const suppression = {
|
|
109
|
+
code,
|
|
110
|
+
isBulkUpload: isCollectionCreate,
|
|
111
|
+
timeout: 0
|
|
112
|
+
};
|
|
113
|
+
suppression.timeout = window.setTimeout(()=>suppressions.delete(rawMessage), ERROR_SUPPRESSION_MS);
|
|
114
|
+
suppressions.set(rawMessage, suppression);
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Leave Payload's default handling intact for malformed error responses.
|
|
118
|
+
}
|
|
119
|
+
return response;
|
|
120
|
+
};
|
|
121
|
+
window.fetch = patchedFetch;
|
|
122
|
+
toast.error = patchedToastError;
|
|
123
|
+
return ()=>{
|
|
124
|
+
suppressions.forEach(({ timeout })=>window.clearTimeout(timeout));
|
|
125
|
+
window.clearTimeout(summaryTimeout);
|
|
126
|
+
if (window.fetch === patchedFetch) {
|
|
127
|
+
window.fetch = originalFetch;
|
|
128
|
+
}
|
|
129
|
+
if (toast.error === patchedToastError) {
|
|
130
|
+
;
|
|
131
|
+
toast.error = originalToastError;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CloudLimitErrorCode } from '../shared.js';
|
|
2
|
+
export type Translate = (key: string) => string;
|
|
3
|
+
export declare function dismissSubmittingToast(label: string): void;
|
|
4
|
+
export declare function showCloudLimitToast(code: CloudLimitErrorCode, translate: Translate): void;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { toast } from '@payloadcms/ui';
|
|
3
|
+
import { cloudLimitToastKeyByCode } from '../shared.js';
|
|
4
|
+
const TOAST_ID_PREFIX = 'figma-cloud-limit';
|
|
5
|
+
export function dismissSubmittingToast(label) {
|
|
6
|
+
const activeToasts = toast.getToasts();
|
|
7
|
+
for(let index = activeToasts.length - 1; index >= 0; index--){
|
|
8
|
+
const candidate = activeToasts[index];
|
|
9
|
+
if (candidate && !('dismiss' in candidate) && candidate.promise !== undefined && candidate.title === label && candidate.type === 'loading') {
|
|
10
|
+
toast.dismiss(candidate.id);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function showCloudLimitToast(code, translate) {
|
|
16
|
+
toast.info(translate(cloudLimitToastKeyByCode[code]), {
|
|
17
|
+
id: `${TOAST_ID_PREFIX}-${code}`
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const buildBaseMediaResponseHeaders: () => Headers;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payload's server-side upload fetch-back uses the response Content-Type to decide whether to
|
|
3
|
+
* inspect image dimensions. For an active image type such as SVG, omit the sanitized type so
|
|
4
|
+
* Payload falls back to the original upload MIME type. Public file requests never use this helper.
|
|
5
|
+
*/
|
|
6
|
+
export declare const buildClientUploadFetchBackHeaders: ({ fallbackContentType, filename, upstream, }: {
|
|
7
|
+
fallbackContentType?: string;
|
|
8
|
+
filename: string;
|
|
9
|
+
upstream: Headers;
|
|
10
|
+
}) => Headers;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { buildMediaResponseHeaders } from './buildMediaResponseHeaders.js';
|
|
2
|
+
import { getInlineContentType } from './getInlineContentType.js';
|
|
3
|
+
/**
|
|
4
|
+
* Payload's server-side upload fetch-back uses the response Content-Type to decide whether to
|
|
5
|
+
* inspect image dimensions. For an active image type such as SVG, omit the sanitized type so
|
|
6
|
+
* Payload falls back to the original upload MIME type. Public file requests never use this helper.
|
|
7
|
+
*/ export const buildClientUploadFetchBackHeaders = ({ fallbackContentType, filename, upstream })=>{
|
|
8
|
+
const effectiveContentType = upstream.get('Content-Type') || fallbackContentType;
|
|
9
|
+
const upstreamWithFallback = new Headers(upstream);
|
|
10
|
+
if (effectiveContentType && !upstream.get('Content-Type')) {
|
|
11
|
+
upstreamWithFallback.set('Content-Type', effectiveContentType);
|
|
12
|
+
}
|
|
13
|
+
const headers = buildMediaResponseHeaders({
|
|
14
|
+
filename,
|
|
15
|
+
upstream: upstreamWithFallback
|
|
16
|
+
});
|
|
17
|
+
if (effectiveContentType && !getInlineContentType(effectiveContentType)) {
|
|
18
|
+
headers.delete('Content-Type');
|
|
19
|
+
}
|
|
20
|
+
return headers;
|
|
21
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { buildBaseMediaResponseHeaders } from './buildBaseMediaResponseHeaders.js';
|
|
2
|
+
import { getInlineContentType } from './getInlineContentType.js';
|
|
3
|
+
const FORWARDED_MEDIA_RESPONSE_HEADERS = [
|
|
4
|
+
'Cache-Control',
|
|
5
|
+
'Content-Length',
|
|
6
|
+
'Content-Range',
|
|
7
|
+
'ETag',
|
|
8
|
+
'Last-Modified'
|
|
9
|
+
];
|
|
10
|
+
const ATTACHMENT_CSP = "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'";
|
|
11
|
+
export const buildMediaResponseHeaders = ({ filename, upstream })=>{
|
|
12
|
+
const headers = buildBaseMediaResponseHeaders();
|
|
13
|
+
for (const name of FORWARDED_MEDIA_RESPONSE_HEADERS){
|
|
14
|
+
const value = upstream.get(name);
|
|
15
|
+
if (value) {
|
|
16
|
+
headers.set(name, value);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const contentType = getInlineContentType(upstream.get('Content-Type'));
|
|
20
|
+
if (contentType) {
|
|
21
|
+
headers.set('Content-Type', contentType);
|
|
22
|
+
} else {
|
|
23
|
+
headers.set('Content-Type', 'application/octet-stream');
|
|
24
|
+
headers.set('Content-Disposition', attachmentDisposition(filename));
|
|
25
|
+
headers.set('Content-Security-Policy', ATTACHMENT_CSP);
|
|
26
|
+
}
|
|
27
|
+
return headers;
|
|
28
|
+
};
|
|
29
|
+
const attachmentDisposition = (filename)=>{
|
|
30
|
+
let decodedName;
|
|
31
|
+
try {
|
|
32
|
+
decodedName = decodeURIComponent(filename);
|
|
33
|
+
} catch {
|
|
34
|
+
decodedName = 'download';
|
|
35
|
+
}
|
|
36
|
+
const sanitizedName = sanitizeFilename(decodedName);
|
|
37
|
+
const safeName = Array.from(sanitizedName).slice(0, 255).join('') || 'download';
|
|
38
|
+
const asciiName = safeName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
|
39
|
+
const utf8Name = encodeURIComponent(safeName).replace(/[!'()*]/g, (character)=>`%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
40
|
+
return `attachment; filename="${asciiName}"; filename*=UTF-8''${utf8Name}`;
|
|
41
|
+
};
|
|
42
|
+
const sanitizeFilename = (filename)=>Array.from(filename, (character)=>{
|
|
43
|
+
const codePoint = character.codePointAt(0);
|
|
44
|
+
return codePoint < 32 || codePoint === 127 || character === '/' || character === '\\' ? '_' : character;
|
|
45
|
+
}).join('');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const getInlineContentType: (value: null | string | undefined) => string | null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Keep this allowlist aligned with Gatekeeper's presignedMediaHeaders.ts. The plugin cannot
|
|
2
|
+
// import the private Gatekeeper service, but both same-origin media proxies need the same policy.
|
|
3
|
+
const INLINE_CONTENT_TYPES = new Set([
|
|
4
|
+
'application/font-woff',
|
|
5
|
+
'audio/aac',
|
|
6
|
+
'audio/flac',
|
|
7
|
+
'audio/mp4',
|
|
8
|
+
'audio/mpeg',
|
|
9
|
+
'audio/ogg',
|
|
10
|
+
'audio/wav',
|
|
11
|
+
'audio/webm',
|
|
12
|
+
'font/otf',
|
|
13
|
+
'font/ttf',
|
|
14
|
+
'font/woff',
|
|
15
|
+
'font/woff2',
|
|
16
|
+
'image/avif',
|
|
17
|
+
'image/bmp',
|
|
18
|
+
'image/gif',
|
|
19
|
+
'image/jpeg',
|
|
20
|
+
'image/png',
|
|
21
|
+
'image/vnd.microsoft.icon',
|
|
22
|
+
'image/webp',
|
|
23
|
+
'image/x-icon',
|
|
24
|
+
'video/mp4',
|
|
25
|
+
'video/mpeg',
|
|
26
|
+
'video/ogg',
|
|
27
|
+
'video/quicktime',
|
|
28
|
+
'video/webm'
|
|
29
|
+
]);
|
|
30
|
+
export const getInlineContentType = (value)=>{
|
|
31
|
+
const normalized = value?.split(';', 1).at(0)?.trim().toLowerCase();
|
|
32
|
+
return normalized && INLINE_CONTENT_TYPES.has(normalized) ? normalized : null;
|
|
33
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const hasSupportedContentEncoding: (headers: Headers) => boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const mediaErrorResponse: (status: number, body: string, contentRange?: string) => Response;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { buildBaseMediaResponseHeaders } from './buildBaseMediaResponseHeaders.js';
|
|
2
|
+
export const mediaErrorResponse = (status, body, contentRange)=>{
|
|
3
|
+
const headers = buildBaseMediaResponseHeaders();
|
|
4
|
+
headers.set('Content-Type', 'text/plain;charset=UTF-8');
|
|
5
|
+
if (contentRange) {
|
|
6
|
+
headers.set('Content-Range', contentRange);
|
|
7
|
+
}
|
|
8
|
+
return new Response(body, {
|
|
9
|
+
headers,
|
|
10
|
+
status
|
|
11
|
+
});
|
|
12
|
+
};
|
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
import { getFilePrefix } from '@payloadcms/plugin-cloud-storage/utilities';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { isImage } from 'payload/shared';
|
|
4
|
+
import { buildClientUploadFetchBackHeaders } from './buildClientUploadFetchBackHeaders.js';
|
|
5
|
+
import { buildMediaResponseHeaders } from './buildMediaResponseHeaders.js';
|
|
6
|
+
import { hasSupportedContentEncoding } from './hasSupportedContentEncoding.js';
|
|
7
|
+
import { mediaErrorResponse } from './mediaErrorResponse.js';
|
|
4
8
|
import { parseUploadReference } from './utilities/index.js';
|
|
9
|
+
const isRunningInLambda = ()=>process.env.AWS_EXECUTION_ENV?.startsWith('AWS_Lambda_') === true;
|
|
10
|
+
const shouldReturnRedirect = ({ uploadReference })=>{
|
|
11
|
+
if (!isRunningInLambda()) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
if (!uploadReference) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
const { mimeType } = parseUploadReference(uploadReference);
|
|
18
|
+
return Boolean(mimeType && !isImage(mimeType));
|
|
19
|
+
};
|
|
20
|
+
const cancelResponseBody = async (response)=>{
|
|
21
|
+
try {
|
|
22
|
+
await response.body?.cancel();
|
|
23
|
+
} catch {
|
|
24
|
+
// The response will not be consumed after an error; cancellation is best-effort cleanup.
|
|
25
|
+
}
|
|
26
|
+
};
|
|
5
27
|
export const getHandler = ({ client, collection })=>{
|
|
6
28
|
return async (req, { params: { filename, uploadReference } })=>{
|
|
7
29
|
try {
|
|
@@ -25,29 +47,68 @@ export const getHandler = ({ client, collection })=>{
|
|
|
25
47
|
status: 500
|
|
26
48
|
});
|
|
27
49
|
}
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
});
|
|
50
|
+
// Lambda responses have a 6 MB limit, so deployed apps redirect to Gatekeeper, which
|
|
51
|
+
// validates the signed URL and streams the file. Other runtimes do not have Gatekeeper
|
|
52
|
+
// in front of them and must stream the file through Payload instead.
|
|
53
|
+
// An image uploadReference marks an internal, one-time fetch-back. Payload core consumes
|
|
54
|
+
// the body to size the image, so a redirect would leave it empty even in Lambda.
|
|
55
|
+
if (shouldReturnRedirect({
|
|
56
|
+
uploadReference
|
|
57
|
+
})) {
|
|
58
|
+
return Response.redirect(data.url, 302);
|
|
59
|
+
}
|
|
60
|
+
const requestHeaders = new Headers({
|
|
61
|
+
'Accept-Encoding': 'identity'
|
|
62
|
+
});
|
|
63
|
+
const range = req.headers.get('Range');
|
|
64
|
+
if (range) {
|
|
65
|
+
requestHeaders.set('Range', range);
|
|
66
|
+
const ifRange = req.headers.get('If-Range');
|
|
67
|
+
if (ifRange) {
|
|
68
|
+
requestHeaders.set('If-Range', ifRange);
|
|
48
69
|
}
|
|
49
70
|
}
|
|
50
|
-
|
|
71
|
+
let fileResponse;
|
|
72
|
+
try {
|
|
73
|
+
fileResponse = await fetch(data.url, {
|
|
74
|
+
credentials: 'omit',
|
|
75
|
+
headers: requestHeaders,
|
|
76
|
+
redirect: 'manual',
|
|
77
|
+
referrerPolicy: 'no-referrer',
|
|
78
|
+
signal: req.signal
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
req.payload.logger.error('Failed to fetch file from storage');
|
|
82
|
+
return mediaErrorResponse(500, 'Internal Server Error');
|
|
83
|
+
}
|
|
84
|
+
if (fileResponse.status === 416 && range) {
|
|
85
|
+
await cancelResponseBody(fileResponse);
|
|
86
|
+
return mediaErrorResponse(416, 'Range Not Satisfiable', fileResponse.headers.get('Content-Range') ?? undefined);
|
|
87
|
+
}
|
|
88
|
+
if (!fileResponse.ok) {
|
|
89
|
+
await cancelResponseBody(fileResponse);
|
|
90
|
+
req.payload.logger.error(`Failed to fetch file from storage: ${fileResponse.status}`);
|
|
91
|
+
return mediaErrorResponse(500, 'Internal Server Error');
|
|
92
|
+
}
|
|
93
|
+
if (!hasSupportedContentEncoding(fileResponse.headers)) {
|
|
94
|
+
await cancelResponseBody(fileResponse);
|
|
95
|
+
req.payload.logger.error('Unsupported Content-Encoding from storage');
|
|
96
|
+
return mediaErrorResponse(502, 'Bad Gateway');
|
|
97
|
+
}
|
|
98
|
+
const clientUploadMimeType = uploadReference ? parseUploadReference(uploadReference).mimeType : undefined;
|
|
99
|
+
const responseHeaders = uploadReference ? buildClientUploadFetchBackHeaders({
|
|
100
|
+
fallbackContentType: clientUploadMimeType,
|
|
101
|
+
filename,
|
|
102
|
+
upstream: fileResponse.headers
|
|
103
|
+
}) : buildMediaResponseHeaders({
|
|
104
|
+
filename,
|
|
105
|
+
upstream: fileResponse.headers
|
|
106
|
+
});
|
|
107
|
+
return new Response(fileResponse.body, {
|
|
108
|
+
headers: responseHeaders,
|
|
109
|
+
status: fileResponse.status,
|
|
110
|
+
statusText: fileResponse.statusText
|
|
111
|
+
});
|
|
51
112
|
} catch (err) {
|
|
52
113
|
req.payload.logger.error({
|
|
53
114
|
err,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { collectSSGAssets, collectStaticAssets } from '../asset-collection.js';
|
|
3
|
+
import { collectSSGAssets, collectStaticAssets, collectStaticMetadataAssets } from '../asset-collection.js';
|
|
4
4
|
import { buildLambdaZip } from '../build-lambda-zip.js';
|
|
5
5
|
import { resolveOutputPath } from '../deploy-output/resolveOutputPath.js';
|
|
6
6
|
import { collectFilesRecursive } from '../fs-utils.js';
|
|
@@ -19,10 +19,7 @@ export class NextjsAdapter {
|
|
|
19
19
|
} catch {
|
|
20
20
|
// No public directory
|
|
21
21
|
}
|
|
22
|
-
const
|
|
23
|
-
...staticAssets,
|
|
24
|
-
...publicAssets
|
|
25
|
-
];
|
|
22
|
+
const metadataAssets = await collectStaticMetadataAssets(projectPath, output);
|
|
26
23
|
const pathMap = {};
|
|
27
24
|
for (const key of staticAssets){
|
|
28
25
|
pathMap[key] = path.relative(projectPath, path.join(outputPath, key.replace(/^_next\//, '')));
|
|
@@ -30,10 +27,14 @@ export class NextjsAdapter {
|
|
|
30
27
|
for (const key of publicAssets){
|
|
31
28
|
pathMap[key] = path.join('public', key);
|
|
32
29
|
}
|
|
30
|
+
for (const key of metadataAssets.keys){
|
|
31
|
+
pathMap[key] = metadataAssets.pathMap[key];
|
|
32
|
+
}
|
|
33
|
+
const uploadKeys = Object.keys(pathMap);
|
|
33
34
|
return {
|
|
34
35
|
pathMap,
|
|
35
|
-
routes:
|
|
36
|
-
uploadKeys
|
|
36
|
+
routes: uploadKeys.map((key)=>`/${key}`),
|
|
37
|
+
uploadKeys
|
|
37
38
|
};
|
|
38
39
|
}
|
|
39
40
|
async collectPages(projectPath, output) {
|
|
@@ -27,7 +27,7 @@ export type SSGAssets = {
|
|
|
27
27
|
/**
|
|
28
28
|
* Collect SSG asset paths by parsing .next/prerender-manifest.json
|
|
29
29
|
*
|
|
30
|
-
* For each route in the manifest's `routes` object, collects three files:
|
|
30
|
+
* For each page route in the manifest's `routes` object, collects three files:
|
|
31
31
|
* - {route}.html (full HTML)
|
|
32
32
|
* - {route}.rsc (React Server Components payload)
|
|
33
33
|
* - {route}.meta (response headers JSON)
|
|
@@ -39,6 +39,14 @@ export type SSGAssets = {
|
|
|
39
39
|
* @returns SSG asset keys and a pathMap for filesystem resolution
|
|
40
40
|
*/
|
|
41
41
|
export declare function collectSSGAssets(projectPath: string, output?: string): Promise<SSGAssets>;
|
|
42
|
+
/**
|
|
43
|
+
* Collect file-based Next.js metadata icons that were prerendered as static route handlers.
|
|
44
|
+
*
|
|
45
|
+
* Maps public asset keys such as `shop/icon1.png` to their raw
|
|
46
|
+
* `.next/server/app/shop/icon1.png.body` output.
|
|
47
|
+
* Documentation: https://nextjs.org/docs/app/api-reference/file-conventions/metadata/app-icons
|
|
48
|
+
*/
|
|
49
|
+
export declare function collectStaticMetadataAssets(projectPath: string, output?: string): Promise<SSGAssets>;
|
|
42
50
|
/**
|
|
43
51
|
* Create Lambda deployment zip
|
|
44
52
|
*
|
|
@@ -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 route in the manifest's `routes` object, collects three files:
|
|
80
|
+
* For each page route in the manifest's `routes` object, collects three files:
|
|
81
81
|
* - {route}.html (full HTML)
|
|
82
82
|
* - {route}.rsc (React Server Components payload)
|
|
83
83
|
* - {route}.meta (response headers JSON)
|
|
@@ -113,7 +113,13 @@ const MAX_ASSET_SIZE = 50 * 1024 * 1024 // 50MB
|
|
|
113
113
|
'.rsc',
|
|
114
114
|
'.meta'
|
|
115
115
|
];
|
|
116
|
-
for (const routeKey of Object.
|
|
116
|
+
for (const [routeKey, route] of Object.entries(manifest.routes)){
|
|
117
|
+
// Current manifests classify app pages explicitly. Older App Router manifests
|
|
118
|
+
// can be identified by their RSC data route. Other entries may be route handlers.
|
|
119
|
+
const isPageRoute = route.routeType === 'page' || route.routeType === undefined && typeof route.dataRoute === 'string' && route.dataRoute.endsWith('.rsc');
|
|
120
|
+
if (!isPageRoute) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
117
123
|
// Strip leading slash for S3 key; handle root "/" → "index"
|
|
118
124
|
const stripped = routeKey === '/' ? 'index' : routeKey.replace(/^\//, '');
|
|
119
125
|
for (const ext of extensions){
|
|
@@ -127,6 +133,55 @@ const MAX_ASSET_SIZE = 50 * 1024 * 1024 // 50MB
|
|
|
127
133
|
pathMap
|
|
128
134
|
};
|
|
129
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Collect file-based Next.js metadata icons that were prerendered as static route handlers.
|
|
138
|
+
*
|
|
139
|
+
* Maps public asset keys such as `shop/icon1.png` to their raw
|
|
140
|
+
* `.next/server/app/shop/icon1.png.body` output.
|
|
141
|
+
* Documentation: https://nextjs.org/docs/app/api-reference/file-conventions/metadata/app-icons
|
|
142
|
+
*/ export async function collectStaticMetadataAssets(projectPath, output) {
|
|
143
|
+
const outputPath = resolveOutputPath(projectPath, 'nextjs', output);
|
|
144
|
+
const manifestPath = path.join(outputPath, 'prerender-manifest.json');
|
|
145
|
+
let manifest;
|
|
146
|
+
try {
|
|
147
|
+
const raw = await fs.readFile(manifestPath, 'utf-8');
|
|
148
|
+
manifest = JSON.parse(raw);
|
|
149
|
+
} catch {
|
|
150
|
+
return {
|
|
151
|
+
keys: [],
|
|
152
|
+
pathMap: {}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (!manifest.routes || typeof manifest.routes !== 'object') {
|
|
156
|
+
return {
|
|
157
|
+
keys: [],
|
|
158
|
+
pathMap: {}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const keys = [];
|
|
162
|
+
const pathMap = {};
|
|
163
|
+
for (const [routeKey, route] of Object.entries(manifest.routes)){
|
|
164
|
+
if (route.routeType !== 'route' || route.compute !== 'static' || route.response !== 'complete' || !isFileBasedMetadataIconRoute(routeKey)) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const assetKey = routeKey.replace(/^\/+/, '');
|
|
168
|
+
const bodyPath = path.join(outputPath, 'server', 'app', `${assetKey}.body`);
|
|
169
|
+
try {
|
|
170
|
+
const bodyStat = await fs.stat(bodyPath);
|
|
171
|
+
if (!bodyStat.isFile()) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
keys.push(assetKey);
|
|
178
|
+
pathMap[assetKey] = path.relative(projectPath, bodyPath);
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
keys,
|
|
182
|
+
pathMap
|
|
183
|
+
};
|
|
184
|
+
}
|
|
130
185
|
/**
|
|
131
186
|
* Create Lambda deployment zip
|
|
132
187
|
*
|
|
@@ -137,3 +192,10 @@ const MAX_ASSET_SIZE = 50 * 1024 * 1024 // 50MB
|
|
|
137
192
|
*/ export async function createLambdaZip(projectPath) {
|
|
138
193
|
await buildLambdaZip(projectPath);
|
|
139
194
|
}
|
|
195
|
+
function isFileBasedMetadataIconRoute(routeKey) {
|
|
196
|
+
const basename = path.posix.basename(routeKey);
|
|
197
|
+
if (routeKey === '/favicon.ico') {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
return /^icon\d*(?:-[0-9a-z]{6})?\.(?:ico|jpe?g|png|svg)$/.test(basename) || /^apple-icon\d*(?:-[0-9a-z]{6})?\.(?:jpe?g|png)$/.test(basename);
|
|
201
|
+
}
|
package/dist/utils/s3-upload.js
CHANGED
|
@@ -141,16 +141,19 @@ const INITIAL_RETRY_DELAY = 1000 // 1 second
|
|
|
141
141
|
const fsPath = pathMap?.[assetPath] ?? assetPath.replace(/^_next\//, '.next/');
|
|
142
142
|
const fullPath = path.join(projectPath, fsPath);
|
|
143
143
|
const signedUrl = assetUrls[assetPath];
|
|
144
|
-
|
|
144
|
+
const contentType = MIME_TYPES[path.extname(assetPath).toLowerCase()];
|
|
145
|
+
await uploadFile(fullPath, signedUrl, contentType);
|
|
145
146
|
results.assetsUploaded++;
|
|
146
147
|
// Track bytes uploaded
|
|
147
148
|
const stats = await fs.stat(fullPath);
|
|
148
149
|
results.totalBytesUploaded += stats.size;
|
|
149
150
|
// Report progress
|
|
150
151
|
onProgress?.(results.assetsUploaded, assetPaths.length);
|
|
152
|
+
return undefined;
|
|
151
153
|
} catch (error) {
|
|
152
154
|
results.assetsFailed++;
|
|
153
155
|
log.debug(`Failed to upload ${assetPath}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
156
|
+
return undefined;
|
|
154
157
|
}
|
|
155
158
|
}));
|
|
156
159
|
}
|