canopycms 0.0.67 → 0.0.68-int.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -17
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +17 -20
- package/dist/api/admin.d.ts +30 -9
- package/dist/api/admin.js +32 -2
- package/dist/api/assets.js +84 -76
- package/dist/api/branch-create-window.d.ts +12 -0
- package/dist/api/branch-create-window.js +13 -0
- package/dist/api/branch-review.d.ts +3 -3
- package/dist/api/branch-review.js +14 -5
- package/dist/api/branch-status.d.ts +5 -5
- package/dist/api/branch-status.js +26 -6
- package/dist/api/branch-withdraw.d.ts +2 -2
- package/dist/api/branch-withdraw.js +7 -2
- package/dist/api/branch.d.ts +15 -2
- package/dist/api/branch.js +118 -92
- package/dist/api/client.d.ts +18 -6
- package/dist/api/client.js +79 -12
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +44 -41
- package/dist/api/entries.js +16 -15
- package/dist/api/github-sync.d.ts +19 -1
- package/dist/api/github-sync.js +57 -10
- package/dist/api/index.d.ts +1 -1
- package/dist/api/reference-options.js +1 -1
- package/dist/api/resolve-references.js +14 -40
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/api/user.d.ts +3 -0
- package/dist/api/user.js +3 -0
- package/dist/assets/asset-url.d.ts +22 -16
- package/dist/assets/asset-url.js +42 -25
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- package/dist/assets/finalize.js +3 -0
- package/dist/assets/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +153 -0
- package/dist/assets/materialize.js +402 -0
- package/dist/assets/store-local.d.ts +19 -5
- package/dist/assets/store-local.js +69 -10
- package/dist/assets/store-s3.d.ts +38 -7
- package/dist/assets/store-s3.js +128 -22
- package/dist/assets/transform-directives.d.ts +52 -13
- package/dist/assets/transform-directives.js +87 -27
- package/dist/assets/transform.d.ts +3 -3
- package/dist/assets/transform.js +13 -8
- package/dist/assets/types.d.ts +31 -4
- package/dist/auth/file-based-auth-cache.js +7 -8
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.d.ts +7 -1
- package/dist/branch-health.js +8 -3
- package/dist/branch-metadata.d.ts +5 -5
- package/dist/branch-metadata.js +44 -36
- package/dist/branch-provisioning.d.ts +167 -0
- package/dist/branch-provisioning.js +501 -0
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-sparse.d.ts +23 -0
- package/dist/branch-sparse.js +118 -0
- package/dist/branch-workspace.d.ts +42 -3
- package/dist/branch-workspace.js +240 -94
- package/dist/build/asset-refs.d.ts +93 -0
- package/dist/build/asset-refs.js +251 -0
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/asset-refs.d.ts +52 -0
- package/dist/cli/asset-refs.js +194 -0
- package/dist/cli/cli.d.ts +15 -1
- package/dist/cli/cli.js +5928 -2195
- package/dist/cli/configured-asset-store.d.ts +12 -0
- package/dist/cli/configured-asset-store.js +46 -0
- package/dist/cli/generate-ai-content.js +2185 -587
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +30 -27
- package/dist/cli/migrate.js +12 -8
- package/dist/cli/sync.js +83 -37
- package/dist/cli/template-files/Dockerfile.cms.template +8 -0
- package/dist/cli/template-files/cdk-app.ts.template +4 -0
- package/dist/cli/template-files/cms-stack.ts.template +23 -12
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/config/helpers.js +1 -2
- package/dist/config/schemas/config.d.ts +19 -32
- package/dist/config/schemas/config.js +9 -11
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/media.d.ts +4 -13
- package/dist/config/schemas/media.js +5 -8
- package/dist/config/schemas/url.d.ts +4 -20
- package/dist/config/schemas/url.js +6 -22
- package/dist/config/types.d.ts +30 -20
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +12 -12
- package/dist/content-listing.js +10 -6
- package/dist/content-reader.d.ts +11 -2
- package/dist/content-reader.js +22 -14
- package/dist/content-store.d.ts +37 -9
- package/dist/content-store.js +70 -43
- package/dist/content-tree.d.ts +1 -1
- package/dist/content-tree.js +2 -2
- package/dist/context.d.ts +27 -9
- package/dist/context.js +154 -70
- package/dist/editor/BranchManager.d.ts +7 -1
- package/dist/editor/BranchManager.js +29 -14
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +2 -6
- package/dist/editor/Editor.d.ts +4 -3
- package/dist/editor/Editor.js +32 -22
- package/dist/editor/FormRenderer.js +34 -18
- package/dist/editor/PreviewFrame.d.ts +24 -0
- package/dist/editor/PreviewFrame.js +140 -0
- package/dist/editor/admin/SystemHealthPanel.js +47 -7
- package/dist/editor/components/BranchesDrawer.d.ts +9 -0
- package/dist/editor/components/BranchesDrawer.js +4 -0
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/context/AssetContext.d.ts +18 -35
- package/dist/editor/context/AssetContext.js +20 -29
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/editor-config.d.ts +1 -2
- package/dist/editor/editor-config.js +0 -23
- package/dist/editor/editor-utils.d.ts +24 -21
- package/dist/editor/editor-utils.js +75 -62
- package/dist/editor/fields/BlockField.d.ts +1 -0
- package/dist/editor/fields/BlockField.js +6 -4
- package/dist/editor/fields/CodeField.d.ts +1 -0
- package/dist/editor/fields/CodeField.js +2 -2
- package/dist/editor/fields/DateTimeField.d.ts +1 -0
- package/dist/editor/fields/DateTimeField.js +2 -2
- package/dist/editor/fields/FieldDescription.d.ts +16 -0
- package/dist/editor/fields/FieldDescription.js +11 -0
- package/dist/editor/fields/ImageField.d.ts +1 -0
- package/dist/editor/fields/ImageField.js +3 -2
- package/dist/editor/fields/InlineGroupField.js +4 -1
- package/dist/editor/fields/MarkdownField.d.ts +1 -0
- package/dist/editor/fields/MarkdownField.js +115 -20
- package/dist/editor/fields/NumberField.d.ts +1 -0
- package/dist/editor/fields/NumberField.js +2 -2
- package/dist/editor/fields/NumberListField.d.ts +1 -0
- package/dist/editor/fields/NumberListField.js +2 -2
- package/dist/editor/fields/ObjectField.d.ts +1 -0
- package/dist/editor/fields/ObjectField.js +5 -2
- package/dist/editor/fields/ReferenceField.d.ts +1 -0
- package/dist/editor/fields/ReferenceField.js +5 -4
- package/dist/editor/fields/SelectField.d.ts +1 -0
- package/dist/editor/fields/SelectField.js +2 -2
- package/dist/editor/fields/StringListField.d.ts +1 -0
- package/dist/editor/fields/StringListField.js +2 -2
- package/dist/editor/fields/TextField.d.ts +1 -0
- package/dist/editor/fields/TextField.js +2 -2
- package/dist/editor/fields/ToggleField.d.ts +1 -0
- package/dist/editor/fields/ToggleField.js +7 -4
- package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
- package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
- package/dist/editor/fields/mdx-jsx-support.js +137 -0
- package/dist/editor/hooks/create-branch-request.d.ts +20 -0
- package/dist/editor/hooks/create-branch-request.js +52 -0
- package/dist/editor/hooks/useBranchActions.d.ts +13 -2
- package/dist/editor/hooks/useBranchActions.js +48 -17
- package/dist/editor/hooks/useBranchManager.d.ts +21 -0
- package/dist/editor/hooks/useBranchManager.js +204 -37
- package/dist/editor/hooks/useBranchesData.d.ts +6 -0
- package/dist/editor/hooks/useBranchesData.js +9 -3
- package/dist/editor/hooks/useCommentSystem.js +25 -6
- package/dist/editor/hooks/useDraftManager.d.ts +16 -1
- package/dist/editor/hooks/useDraftManager.js +193 -36
- package/dist/editor/hooks/useEntriesData.js +3 -3
- package/dist/editor/hooks/useEntryManager.d.ts +2 -1
- package/dist/editor/hooks/useEntryManager.js +34 -19
- package/dist/editor/media/AssetCard.d.ts +1 -1
- package/dist/editor/media/crop-math.d.ts +3 -4
- package/dist/editor/media/crop-math.js +11 -22
- package/dist/editor/media/editor-image-src.d.ts +7 -0
- package/dist/editor/media/editor-image-src.js +12 -0
- package/dist/editor/preview-asset-base.d.ts +8 -0
- package/dist/editor/preview-asset-base.js +13 -0
- package/dist/editor/preview-bridge.d.ts +9 -21
- package/dist/editor/preview-bridge.js +19 -139
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/editor/theme.js +2 -2
- package/dist/entry-schema-registry.d.ts +4 -4
- package/dist/entry-schema-registry.js +10 -6
- package/dist/entry-schema.d.ts +27 -4
- package/dist/entry-schema.js +20 -3
- package/dist/git-manager.d.ts +173 -6
- package/dist/git-manager.js +594 -76
- package/dist/github-service.d.ts +15 -0
- package/dist/github-service.js +19 -1
- package/dist/http/handler.js +37 -13
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/types.d.ts +2 -0
- package/dist/http/worker-not-ready.d.ts +10 -0
- package/dist/http/worker-not-ready.js +24 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +1 -4
- package/dist/paths/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -2
- package/dist/paths/branch.js +16 -10
- package/dist/paths/index.d.ts +3 -3
- package/dist/paths/index.js +3 -3
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/paths/validation.js +0 -2
- package/dist/preview.d.ts +8 -0
- package/dist/preview.js +7 -0
- package/dist/reference-resolver.d.ts +5 -21
- package/dist/reference-resolver.js +9 -44
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/schema/schema-store.d.ts +54 -31
- package/dist/schema/schema-store.js +77 -31
- package/dist/server.d.ts +64 -9
- package/dist/server.js +48 -8
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.d.ts +23 -3
- package/dist/settings-workspace.js +197 -45
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.d.ts +12 -1
- package/dist/sync-core.js +31 -13
- package/dist/task-queue/worker-status.d.ts +8 -0
- package/dist/task-queue/worker-status.js +17 -0
- package/dist/types.d.ts +31 -2
- package/dist/utils/content-serialize.d.ts +5 -2
- package/dist/utils/content-serialize.js +98 -24
- package/dist/utils/content-write-lock.d.ts +18 -7
- package/dist/utils/content-write-lock.js +18 -9
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +32 -0
- package/dist/utils/git.js +42 -0
- package/dist/utils/occ-json-write.js +10 -2
- package/dist/utils/provision-log.d.ts +25 -0
- package/dist/utils/provision-log.js +49 -0
- package/dist/utils/provisioning-lock.d.ts +35 -5
- package/dist/utils/provisioning-lock.js +73 -12
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/sanitize-href.d.ts +8 -22
- package/dist/utils/sanitize-href.js +11 -28
- package/dist/utils/url-prefix.d.ts +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/utils/yaml-source-splice.d.ts +58 -0
- package/dist/utils/yaml-source-splice.js +515 -0
- package/dist/validation/entry-validator.js +9 -3
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/cms-worker.d.ts +8 -3
- package/dist/worker/cms-worker.js +38 -2
- package/dist/worker/git-sync.d.ts +30 -12
- package/dist/worker/git-sync.js +253 -58
- package/dist/worker/history-rewrite.d.ts +1 -1
- package/dist/worker/history-rewrite.js +1 -1
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +95 -23
- package/dist/worker/remote-git-maintenance.d.ts +3 -0
- package/dist/worker/remote-git-maintenance.js +11 -0
- package/dist/worker/sparse-cone.d.ts +20 -0
- package/dist/worker/sparse-cone.js +132 -0
- package/dist/worker/task-runner.js +99 -14
- package/dist/worker/worker-context.d.ts +6 -1
- package/package.json +8 -2
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing transform outputs into the store. Server-only.
|
|
3
|
+
*
|
|
4
|
+
* `storeTransform` computes one key and is shared by the authenticated raw route
|
|
5
|
+
* (api/assets.ts), `materializeAssets` (the batch run that writes every key a build references
|
|
6
|
+
* before that build is released) and canopycms-cdk's lazy transform Lambda.
|
|
7
|
+
*/
|
|
8
|
+
import { getErrorMessage, isNodeError } from '../utils/error.js';
|
|
9
|
+
import { ASSET_PREFIXES } from './asset-prefixes.js';
|
|
10
|
+
import { loadSharp } from './sharp-loader.js';
|
|
11
|
+
import { applyTransform } from './transform.js';
|
|
12
|
+
import { canonicalizeTransformPath } from './transform-directives.js';
|
|
13
|
+
/** Every transform output is stored under a content-addressed key, so it never changes. */
|
|
14
|
+
export const TRANSFORM_CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
|
15
|
+
/**
|
|
16
|
+
* The object tag on every derivative the lazy transform Lambda writes. Its bucket's `assets/t/`
|
|
17
|
+
* expiry filters on it, so what `materializeAssets` writes, which the Lambda's allowlist may refuse
|
|
18
|
+
* to recompute, is never expired. canopycms-cdk's `AssetSupport` copies these two strings as
|
|
19
|
+
* literals.
|
|
20
|
+
*/
|
|
21
|
+
export const LAZY_TRANSFORM_TAG = { key: 'canopy-transform', value: 'lazy' };
|
|
22
|
+
/**
|
|
23
|
+
* Compute the transform `parsed` names from the asset's original and write it under `key`: the
|
|
24
|
+
* canonical key, or that key beneath an output prefix. `parsed` must come from
|
|
25
|
+
* `canonicalizeTransformPath`, so the stored pixels always match their key. A rejection is a fact
|
|
26
|
+
* about the asset or the URL and is never worth retrying; a thrown error belongs to the store or
|
|
27
|
+
* the environment. A 404's `error` names what was missing, so a caller answering a browser
|
|
28
|
+
* replaces it.
|
|
29
|
+
*/
|
|
30
|
+
export async function storeTransform(store, parsed, key, options = {}) {
|
|
31
|
+
const meta = await store.getMeta(parsed.hash32);
|
|
32
|
+
if (!meta) {
|
|
33
|
+
return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no meta in the store` };
|
|
34
|
+
}
|
|
35
|
+
if (meta.kind !== 'raster') {
|
|
36
|
+
return { ok: false, status: 400, error: 'Not a raster asset - svg/pdf are served statically' };
|
|
37
|
+
}
|
|
38
|
+
// The slug is decorative in the URL but load-bearing in the stored key, so it must equal the
|
|
39
|
+
// asset's real slug — the parser only enforces `[a-z0-9-]+`, and any other string that passes
|
|
40
|
+
// it aliases the same image into a new cache key.
|
|
41
|
+
if (parsed.slug !== meta.slug) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
status: 404,
|
|
45
|
+
error: `Slug '${parsed.slug}' is not asset ${parsed.hash32}'s slug '${meta.slug}'`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// Without an explicit `f=`, the transform keeps the source format, so the URL's `{ext}` must be
|
|
49
|
+
// the source's real ext. The parser cannot check this: it does not know the source format.
|
|
50
|
+
const requestedFormat = parsed.directives.identity ? undefined : parsed.directives.format;
|
|
51
|
+
if (requestedFormat === undefined && parsed.ext !== meta.ext) {
|
|
52
|
+
return { ok: false, status: 400, error: 'Extension does not match the source format' };
|
|
53
|
+
}
|
|
54
|
+
const original = await store.readOriginal(parsed.hash32, meta.ext);
|
|
55
|
+
if (!original) {
|
|
56
|
+
return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no original in the store` };
|
|
57
|
+
}
|
|
58
|
+
const transformed = await applyTransform({ data: original.data, ext: original.ext }, parsed.directives);
|
|
59
|
+
if (!transformed.ok) {
|
|
60
|
+
return { ok: false, status: transformed.status, error: transformed.error };
|
|
61
|
+
}
|
|
62
|
+
const stored = await store.putPublicObject({
|
|
63
|
+
key,
|
|
64
|
+
data: transformed.data,
|
|
65
|
+
contentType: transformed.contentType,
|
|
66
|
+
cacheControl: TRANSFORM_CACHE_CONTROL,
|
|
67
|
+
tags: options.tags,
|
|
68
|
+
});
|
|
69
|
+
return { ok: true, data: transformed.data, contentType: transformed.contentType, stored };
|
|
70
|
+
}
|
|
71
|
+
/** The `schemaVersion` of every `MaterializeReport`; a consumer gating on the JSON checks it. */
|
|
72
|
+
export const MATERIALIZE_REPORT_SCHEMA_VERSION = 1;
|
|
73
|
+
/** sharp cannot load in this process, so nothing missing can be produced here. */
|
|
74
|
+
export class SharpUnavailableError extends Error {
|
|
75
|
+
constructor(cause) {
|
|
76
|
+
super(`sharp failed to load, so the missing transform outputs cannot be produced in this ` +
|
|
77
|
+
`environment: ${getErrorMessage(cause)}. Install sharp for this platform and rerun.`);
|
|
78
|
+
this.name = 'SharpUnavailableError';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** `MaterializeOptions.outputPrefix` broke a rule of `assertValidOutputPrefix`; nothing was read or written. */
|
|
82
|
+
export class InvalidOutputPrefixError extends Error {
|
|
83
|
+
constructor(prefix, reason) {
|
|
84
|
+
super(`Invalid output prefix ${JSON.stringify(prefix)}: ${reason}`);
|
|
85
|
+
this.name = 'InvalidOutputPrefixError';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const OUTPUT_PREFIX_SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
|
|
89
|
+
const CANOPY_PREFIX_SEGMENTS = Object.values(ASSET_PREFIXES).map((prefix) => prefix.split('/'));
|
|
90
|
+
/**
|
|
91
|
+
* Throws `InvalidOutputPrefixError` unless `prefix` is relative, ends in `/`, and is a run of
|
|
92
|
+
* `[A-Za-z0-9._-]` segments, none empty, `.` or `..`, whose first segments are not a canopy
|
|
93
|
+
* prefix. Production trusts whatever is under its own prefixes, so a write beneath one is a write
|
|
94
|
+
* production serves. Compared by segment: `assets-x/` is accepted, `assets/x/` is not.
|
|
95
|
+
*/
|
|
96
|
+
export function assertValidOutputPrefix(prefix) {
|
|
97
|
+
const refuse = (reason) => {
|
|
98
|
+
throw new InvalidOutputPrefixError(prefix, reason);
|
|
99
|
+
};
|
|
100
|
+
if (prefix.startsWith('/'))
|
|
101
|
+
refuse('it must be relative, with no leading /');
|
|
102
|
+
if (!prefix.endsWith('/'))
|
|
103
|
+
refuse('it must end in /');
|
|
104
|
+
const segments = prefix.slice(0, -1).split('/');
|
|
105
|
+
for (const segment of segments) {
|
|
106
|
+
if (segment === '')
|
|
107
|
+
refuse('it has an empty segment');
|
|
108
|
+
if (segment === '.' || segment === '..')
|
|
109
|
+
refuse(`it has a ${segment} segment`);
|
|
110
|
+
if (!OUTPUT_PREFIX_SEGMENT_RE.test(segment)) {
|
|
111
|
+
refuse(`segment ${JSON.stringify(segment)} has a character outside [A-Za-z0-9._-]`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const canopy = CANOPY_PREFIX_SEGMENTS.find((prefixSegments) => prefixSegments.every((segment, i) => segments[i] === segment));
|
|
115
|
+
if (canopy)
|
|
116
|
+
refuse(`it begins with the canopy prefix ${canopy.join('/')}/`);
|
|
117
|
+
}
|
|
118
|
+
const DEFAULT_CONCURRENCY = 8;
|
|
119
|
+
const DEFAULT_TRANSFORM_CONCURRENCY = 2;
|
|
120
|
+
// eslint-disable-next-line security/detect-non-literal-regexp -- built from a constant
|
|
121
|
+
const STATIC_KEY_RE = new RegExp(`^${ASSET_PREFIXES.public}/[a-f0-9]{32}/[a-z0-9-]+\\.[a-z0-9]{1,10}$`);
|
|
122
|
+
const DEFAULT_LIST_THRESHOLD = 100;
|
|
123
|
+
const DEFAULT_ATTEMPTS = 3;
|
|
124
|
+
const DEFAULT_BASE_DELAY_MS = 500;
|
|
125
|
+
const TRANSIENT_NODE_CODES = new Set([
|
|
126
|
+
'ECONNRESET',
|
|
127
|
+
'ECONNREFUSED',
|
|
128
|
+
'ETIMEDOUT',
|
|
129
|
+
'EPIPE',
|
|
130
|
+
'EAI_AGAIN',
|
|
131
|
+
'EBUSY',
|
|
132
|
+
'EMFILE',
|
|
133
|
+
]);
|
|
134
|
+
const TRANSIENT_ERROR_NAMES = new Set([
|
|
135
|
+
'TimeoutError',
|
|
136
|
+
'RequestTimeout',
|
|
137
|
+
'RequestTimeoutException',
|
|
138
|
+
'SlowDown',
|
|
139
|
+
'Throttling',
|
|
140
|
+
'ThrottlingException',
|
|
141
|
+
'ServiceUnavailable',
|
|
142
|
+
'InternalError',
|
|
143
|
+
]);
|
|
144
|
+
/**
|
|
145
|
+
* @internal Exported for tests. Throttling, 5xx and dropped connections are transient. Any other
|
|
146
|
+
* 4xx (AccessDenied, NoSuchBucket) is a configuration fault that a retry only delays.
|
|
147
|
+
*/
|
|
148
|
+
export function isTransientStoreError(err) {
|
|
149
|
+
if (!(err instanceof Error))
|
|
150
|
+
return false;
|
|
151
|
+
if (isNodeError(err) && err.code && TRANSIENT_NODE_CODES.has(err.code))
|
|
152
|
+
return true;
|
|
153
|
+
const shaped = err;
|
|
154
|
+
if (shaped.$retryable)
|
|
155
|
+
return true;
|
|
156
|
+
if (TRANSIENT_ERROR_NAMES.has(err.name))
|
|
157
|
+
return true;
|
|
158
|
+
const status = shaped.$metadata?.httpStatusCode;
|
|
159
|
+
return status !== undefined && (status === 429 || status >= 500);
|
|
160
|
+
}
|
|
161
|
+
/** Run `fn` over `items` with at most `limit` in flight. `fn` must not reject. */
|
|
162
|
+
async function forEachBounded(items, limit, fn) {
|
|
163
|
+
let next = 0;
|
|
164
|
+
const worker = async () => {
|
|
165
|
+
while (next < items.length) {
|
|
166
|
+
const item = items[next++];
|
|
167
|
+
await fn(item);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Make every referenced transform key exist in `store`, and check every static key does. A key
|
|
174
|
+
* already stored is left alone:
|
|
175
|
+
* keys are content-addressed, so an existing object is the right one. sharp is loaded only when
|
|
176
|
+
* something must be transformed, and a sharp that cannot load throws `SharpUnavailableError` rather
|
|
177
|
+
* than failing each key. An invalid `outputPrefix` throws `InvalidOutputPrefixError` first.
|
|
178
|
+
*/
|
|
179
|
+
export async function materializeAssets(options) {
|
|
180
|
+
const { store, outputPrefix } = options;
|
|
181
|
+
if (outputPrefix !== undefined)
|
|
182
|
+
assertValidOutputPrefix(outputPrefix);
|
|
183
|
+
const destOf = (key) => (outputPrefix ?? '') + key;
|
|
184
|
+
const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY);
|
|
185
|
+
const transformConcurrency = Math.max(1, options.transformConcurrency ?? DEFAULT_TRANSFORM_CONCURRENCY);
|
|
186
|
+
const listThreshold = options.listThreshold ?? DEFAULT_LIST_THRESHOLD;
|
|
187
|
+
const attempts = Math.max(1, options.attempts ?? DEFAULT_ATTEMPTS);
|
|
188
|
+
const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
189
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
190
|
+
const withRetry = async (operation) => {
|
|
191
|
+
for (let attempt = 1;; attempt++) {
|
|
192
|
+
try {
|
|
193
|
+
return await operation();
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
if (attempt >= attempts || !isTransientStoreError(err))
|
|
197
|
+
throw err;
|
|
198
|
+
await sleep(baseDelayMs * 2 ** (attempt - 1) * (1 + Math.random()));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const references = new Map();
|
|
203
|
+
const staticKeys = new Set((options.statics ?? []).map((target) => target.key));
|
|
204
|
+
for (const target of [...options.targets, ...(options.statics ?? [])]) {
|
|
205
|
+
const entry = references.get(target.key) ?? { routes: new Set(), files: new Set() };
|
|
206
|
+
target.routes.forEach((route) => entry.routes.add(route));
|
|
207
|
+
target.files.forEach((file) => entry.files.add(file));
|
|
208
|
+
references.set(target.key, entry);
|
|
209
|
+
}
|
|
210
|
+
const outcomes = new Map();
|
|
211
|
+
const fail = (key, failure, error) => outcomes.set(key, { status: 'failed', failure, error });
|
|
212
|
+
// The refs file is an editable file on disk, so every key is re-validated here.
|
|
213
|
+
const transformPrefix = `${ASSET_PREFIXES.transform}/`;
|
|
214
|
+
const valid = [];
|
|
215
|
+
const validStatics = [];
|
|
216
|
+
for (const key of references.keys()) {
|
|
217
|
+
if (staticKeys.has(key)) {
|
|
218
|
+
if (STATIC_KEY_RE.test(key))
|
|
219
|
+
validStatics.push(key);
|
|
220
|
+
else
|
|
221
|
+
fail(key, 'content', 'Not a static asset key (expected assets/{hash32}/{slug}.{ext})');
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (!key.startsWith(transformPrefix)) {
|
|
225
|
+
fail(key, 'content', `Not a transform key (expected a ${transformPrefix} prefix)`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const segments = key.slice(transformPrefix.length).split('/');
|
|
229
|
+
const canonical = canonicalizeTransformPath(segments, 'any');
|
|
230
|
+
if (!canonical.ok) {
|
|
231
|
+
fail(key, 'content', `Invalid transform key: ${canonical.error}`);
|
|
232
|
+
}
|
|
233
|
+
else if (!canonical.isCanonical) {
|
|
234
|
+
fail(key, 'content', `Not canonical; the stored key would be ${transformPrefix}${canonical.canonicalPath}`);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
valid.push({ key, directives: segments[0], parsed: canonical });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const byDirectives = new Map();
|
|
241
|
+
for (const target of valid) {
|
|
242
|
+
const group = byDirectives.get(target.directives) ?? [];
|
|
243
|
+
group.push(target);
|
|
244
|
+
byDirectives.set(target.directives, group);
|
|
245
|
+
}
|
|
246
|
+
const listOnce = async (prefix) => {
|
|
247
|
+
if (!store.listPublicObjectKeys)
|
|
248
|
+
return undefined;
|
|
249
|
+
const listKeys = store.listPublicObjectKeys.bind(store);
|
|
250
|
+
try {
|
|
251
|
+
return new Set(await withRetry(async () => {
|
|
252
|
+
const keys = [];
|
|
253
|
+
for await (const key of listKeys(prefix))
|
|
254
|
+
keys.push(key);
|
|
255
|
+
return keys;
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
// A listing that keeps failing costs only speed: its keys are HEADed instead, and each HEAD
|
|
260
|
+
// reports its own failure.
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
const listingsByDirectives = new Map();
|
|
265
|
+
if (store.listPublicObjectKeys) {
|
|
266
|
+
for (const [directives, group] of byDirectives) {
|
|
267
|
+
if (group.length < listThreshold)
|
|
268
|
+
continue;
|
|
269
|
+
const listPrefix = `${transformPrefix}${directives}/`;
|
|
270
|
+
const dest = await listOnce(destOf(listPrefix));
|
|
271
|
+
const needsCanonical = outputPrefix !== undefined && group.some((target) => !dest?.has(destOf(target.key)));
|
|
272
|
+
const canonical = needsCanonical ? await listOnce(listPrefix) : undefined;
|
|
273
|
+
listingsByDirectives.set(directives, { dest, canonical });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Every presence check goes through here. Without an output prefix a key's destination is the
|
|
277
|
+
// key itself, so this is one listing lookup or HEAD and never `canonical`.
|
|
278
|
+
const resolvePresence = async (key, listings = {}) => {
|
|
279
|
+
const isStored = async (storeKey, listing) => listing ? listing.has(storeKey) : withRetry(() => store.hasPublicObject(storeKey));
|
|
280
|
+
const dest = destOf(key);
|
|
281
|
+
if (await isStored(dest, listings.dest))
|
|
282
|
+
return 'dest';
|
|
283
|
+
if (dest === key)
|
|
284
|
+
return 'absent';
|
|
285
|
+
return (await isStored(key, listings.canonical)) ? 'canonical' : 'absent';
|
|
286
|
+
};
|
|
287
|
+
const presence = new Map();
|
|
288
|
+
await forEachBounded(valid, concurrency, async (target) => {
|
|
289
|
+
try {
|
|
290
|
+
presence.set(target.key, await resolvePresence(target.key, listingsByDirectives.get(target.directives)));
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
fail(target.key, 'store', `Existence check failed: ${getErrorMessage(err)}`);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
await forEachBounded(validStatics, concurrency, async (key) => {
|
|
297
|
+
try {
|
|
298
|
+
let found = await resolvePresence(key);
|
|
299
|
+
// A HEAD cannot tell a missing key from a missing bucket, and a static key has no
|
|
300
|
+
// transform's meta read to tell them apart afterwards; a GET names which.
|
|
301
|
+
if (found === 'absent' && (await withRetry(() => store.readPublicObject(key))) !== null) {
|
|
302
|
+
found = destOf(key) === key ? 'dest' : 'canonical';
|
|
303
|
+
}
|
|
304
|
+
presence.set(key, found);
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
fail(key, 'store', `Existence check failed: ${getErrorMessage(err)}`);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
const toCopy = [];
|
|
311
|
+
for (const [key, found] of presence) {
|
|
312
|
+
if (found === 'dest')
|
|
313
|
+
outcomes.set(key, { status: 'existed' });
|
|
314
|
+
else if (found === 'canonical')
|
|
315
|
+
toCopy.push(key);
|
|
316
|
+
}
|
|
317
|
+
await forEachBounded(toCopy, concurrency, async (key) => {
|
|
318
|
+
try {
|
|
319
|
+
let tries = 0;
|
|
320
|
+
const copied = await withRetry(() => {
|
|
321
|
+
tries++;
|
|
322
|
+
return store.copyPublicObject(key, destOf(key));
|
|
323
|
+
});
|
|
324
|
+
if (copied === 'source-missing') {
|
|
325
|
+
presence.set(key, 'absent');
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
// After a failed try, `already-exists` may be that try's own copy, as for a transform.
|
|
329
|
+
const copiedElsewhere = copied === 'already-exists' && tries === 1;
|
|
330
|
+
outcomes.set(key, { status: copiedElsewhere ? 'existed' : 'copied' });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
fail(key, 'store', `Copy failed: ${getErrorMessage(err)}`);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
for (const key of validStatics) {
|
|
338
|
+
if (presence.get(key) === 'absent') {
|
|
339
|
+
fail(key, 'content', 'No stored object; an svg or pdf is written at upload only');
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const missing = valid.filter((target) => presence.get(target.key) === 'absent');
|
|
343
|
+
if (missing.length > 0) {
|
|
344
|
+
try {
|
|
345
|
+
await loadSharp();
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
throw new SharpUnavailableError(err);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
await forEachBounded(missing, transformConcurrency, async (target) => {
|
|
352
|
+
try {
|
|
353
|
+
let tries = 0;
|
|
354
|
+
const result = await withRetry(() => {
|
|
355
|
+
tries++;
|
|
356
|
+
return storeTransform(store, target.parsed, destOf(target.key));
|
|
357
|
+
});
|
|
358
|
+
if (result.ok) {
|
|
359
|
+
// After a failed attempt, `already-exists` may be that attempt's own write, whose response
|
|
360
|
+
// was lost, so it counts as `created`: a release waiting on its `created` keys then waits
|
|
361
|
+
// on a key it may not have written, never skips one it did.
|
|
362
|
+
const createdElsewhere = result.stored === 'already-exists' && tries === 1;
|
|
363
|
+
outcomes.set(target.key, { status: createdElsewhere ? 'existed' : 'created' });
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
fail(target.key, 'content', `${result.status}: ${result.error}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
fail(target.key, 'store', getErrorMessage(err));
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
const results = [...references.keys()].sort().map((key) => {
|
|
374
|
+
const refs = references.get(key);
|
|
375
|
+
const outcome = outcomes.get(key) ?? {
|
|
376
|
+
status: 'failed',
|
|
377
|
+
failure: 'store',
|
|
378
|
+
error: 'No outcome recorded',
|
|
379
|
+
};
|
|
380
|
+
return {
|
|
381
|
+
key,
|
|
382
|
+
routes: [...(refs?.routes ?? [])].sort(),
|
|
383
|
+
files: [...(refs?.files ?? [])].sort(),
|
|
384
|
+
...outcome,
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
const count = (predicate) => results.filter(predicate).length;
|
|
388
|
+
return {
|
|
389
|
+
schemaVersion: MATERIALIZE_REPORT_SCHEMA_VERSION,
|
|
390
|
+
...(outputPrefix !== undefined ? { outputPrefix } : {}),
|
|
391
|
+
summary: {
|
|
392
|
+
total: results.length,
|
|
393
|
+
existed: count((r) => r.status === 'existed'),
|
|
394
|
+
created: count((r) => r.status === 'created'),
|
|
395
|
+
copied: count((r) => r.status === 'copied'),
|
|
396
|
+
failed: count((r) => r.status === 'failed'),
|
|
397
|
+
contentFailures: count((r) => r.status === 'failed' && r.failure === 'content'),
|
|
398
|
+
storeFailures: count((r) => r.status === 'failed' && r.failure === 'store'),
|
|
399
|
+
},
|
|
400
|
+
results,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* bucket-prefix layout as S3AssetStore (see keys.ts) so content behaves
|
|
4
4
|
* identically across adapters.
|
|
5
5
|
*/
|
|
6
|
-
import type { AssetMeta, AssetStore, BeginUploadInput, PublicObject, StagedUploadTarget } from './types.js';
|
|
6
|
+
import type { AssetMeta, AssetStore, BeginUploadInput, CreateOnlyResult, PublicObject, StagedUploadTarget } from './types.js';
|
|
7
7
|
export interface LocalAssetStoreOptions {
|
|
8
8
|
/** Root directory. The five bucket prefixes are created as subdirectories under it. */
|
|
9
9
|
root: string;
|
|
@@ -26,11 +26,20 @@ export declare class LocalAssetStore implements AssetStore {
|
|
|
26
26
|
*/
|
|
27
27
|
private resolveKey;
|
|
28
28
|
private headersSidecarPath;
|
|
29
|
-
private writeHeadersSidecar;
|
|
30
29
|
private readHeadersSidecar;
|
|
31
30
|
private readFileOrNull;
|
|
32
31
|
private writeFile;
|
|
33
32
|
private deleteFileIfPresent;
|
|
33
|
+
/** Link `tempPath` in at `filePath`; `false` when something is already there. */
|
|
34
|
+
private linkIfAbsent;
|
|
35
|
+
/**
|
|
36
|
+
* Create-only write of a blob and its headers sidecar. Each is written whole to a temp file, then
|
|
37
|
+
* `link()`ed into place, which fails with EEXIST rather than replacing (see docs/concurrency.md).
|
|
38
|
+
* The sidecar is linked first, so a reader never finds a blob without its headers. A sidecar
|
|
39
|
+
* already there was linked by an earlier writer of this key and is kept, as the blob is: the
|
|
40
|
+
* first writer's headers win, as on S3 (a static's Content-Disposition names its uploaded file).
|
|
41
|
+
*/
|
|
42
|
+
private createExclusive;
|
|
34
43
|
beginUpload(_input: BeginUploadInput): Promise<StagedUploadTarget>;
|
|
35
44
|
/**
|
|
36
45
|
* Staging methods accept caller-influenced keys (finalize receives the key
|
|
@@ -47,7 +56,8 @@ export declare class LocalAssetStore implements AssetStore {
|
|
|
47
56
|
ext: string;
|
|
48
57
|
data: Uint8Array;
|
|
49
58
|
contentType: string;
|
|
50
|
-
}): Promise<
|
|
59
|
+
}): Promise<CreateOnlyResult>;
|
|
60
|
+
/** Ignores the `ext` hint: a directory read costs nothing here and needs no permission. */
|
|
51
61
|
readOriginal(hash32: string): Promise<{
|
|
52
62
|
data: Uint8Array;
|
|
53
63
|
ext: string;
|
|
@@ -59,8 +69,12 @@ export declare class LocalAssetStore implements AssetStore {
|
|
|
59
69
|
contentType: string;
|
|
60
70
|
contentDisposition?: string;
|
|
61
71
|
cacheControl?: string;
|
|
62
|
-
|
|
72
|
+
/** Ignored: tags exist for bucket lifecycle rules, and a local store has none. */
|
|
73
|
+
tags?: Readonly<Record<string, string>>;
|
|
74
|
+
}): Promise<CreateOnlyResult>;
|
|
75
|
+
copyPublicObject(sourceKey: string, destKey: string): Promise<CreateOnlyResult | 'source-missing'>;
|
|
63
76
|
readPublicObject(key: string): Promise<PublicObject | null>;
|
|
77
|
+
hasPublicObject(key: string): Promise<boolean>;
|
|
64
78
|
/**
|
|
65
79
|
* Atomic-exclusive create: opens with the `wx` flag so two concurrent
|
|
66
80
|
* writers racing on the same hash32 have exactly one winner ('created')
|
|
@@ -69,7 +83,7 @@ export declare class LocalAssetStore implements AssetStore {
|
|
|
69
83
|
* always clobbers the destination, which would let the second writer
|
|
70
84
|
* silently overwrite the first instead of losing cleanly.
|
|
71
85
|
*/
|
|
72
|
-
putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<
|
|
86
|
+
putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<CreateOnlyResult>;
|
|
73
87
|
getMeta(hash32: string): Promise<AssetMeta | null>;
|
|
74
88
|
listMeta(input?: {
|
|
75
89
|
cursor?: string;
|
|
@@ -13,6 +13,13 @@ import { ASSET_PREFIXES, metaKey, originalKey, stagingKey } from './keys.js';
|
|
|
13
13
|
const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
14
14
|
const DEFAULT_LIST_LIMIT = 24;
|
|
15
15
|
const HEADERS_SIDECAR_SUFFIX = '.headers.json';
|
|
16
|
+
/**
|
|
17
|
+
* Where create-only writes stage their bytes: under the root, so `link()` stays on one filesystem,
|
|
18
|
+
* and outside every prefix, so no read or listing of a prefix ever sees a partial file.
|
|
19
|
+
*/
|
|
20
|
+
const TEMP_DIR = '.asset-tmp';
|
|
21
|
+
/** An original's file name after `{hash32}.`; a sidecar or any other name in the directory fails it. */
|
|
22
|
+
const ORIGINAL_EXT_RE = /^[a-z0-9]+$/;
|
|
16
23
|
export class LocalAssetStore {
|
|
17
24
|
constructor(options) {
|
|
18
25
|
this.capabilities = { directUpload: false };
|
|
@@ -39,9 +46,6 @@ export class LocalAssetStore {
|
|
|
39
46
|
headersSidecarPath(filePath) {
|
|
40
47
|
return `${filePath}${HEADERS_SIDECAR_SUFFIX}`;
|
|
41
48
|
}
|
|
42
|
-
async writeHeadersSidecar(filePath, headers) {
|
|
43
|
-
await atomicWriteFile(this.headersSidecarPath(filePath), JSON.stringify(headers));
|
|
44
|
-
}
|
|
45
49
|
async readHeadersSidecar(filePath) {
|
|
46
50
|
try {
|
|
47
51
|
const raw = await fs.readFile(this.headersSidecarPath(filePath), 'utf-8');
|
|
@@ -78,6 +82,44 @@ export class LocalAssetStore {
|
|
|
78
82
|
// Best-effort sidecar cleanup; a missing sidecar is not an error.
|
|
79
83
|
await fs.unlink(this.headersSidecarPath(filePath)).catch(() => { });
|
|
80
84
|
}
|
|
85
|
+
/** Link `tempPath` in at `filePath`; `false` when something is already there. */
|
|
86
|
+
async linkIfAbsent(tempPath, filePath) {
|
|
87
|
+
try {
|
|
88
|
+
await fs.link(tempPath, filePath);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if (isFileExistsError(err))
|
|
93
|
+
return false;
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Create-only write of a blob and its headers sidecar. Each is written whole to a temp file, then
|
|
99
|
+
* `link()`ed into place, which fails with EEXIST rather than replacing (see docs/concurrency.md).
|
|
100
|
+
* The sidecar is linked first, so a reader never finds a blob without its headers. A sidecar
|
|
101
|
+
* already there was linked by an earlier writer of this key and is kept, as the blob is: the
|
|
102
|
+
* first writer's headers win, as on S3 (a static's Content-Disposition names its uploaded file).
|
|
103
|
+
*/
|
|
104
|
+
async createExclusive(key, data, headers) {
|
|
105
|
+
const filePath = this.resolveKey(key);
|
|
106
|
+
const tempDir = this.resolveKey(TEMP_DIR);
|
|
107
|
+
await Promise.all([
|
|
108
|
+
fs.mkdir(tempDir, { recursive: true }),
|
|
109
|
+
fs.mkdir(path.dirname(filePath), { recursive: true }),
|
|
110
|
+
]);
|
|
111
|
+
const tempBlob = path.join(tempDir, randomUUID());
|
|
112
|
+
const tempHeaders = path.join(tempDir, randomUUID());
|
|
113
|
+
try {
|
|
114
|
+
await fs.writeFile(tempBlob, data);
|
|
115
|
+
await fs.writeFile(tempHeaders, JSON.stringify(headers), 'utf-8');
|
|
116
|
+
await this.linkIfAbsent(tempHeaders, this.headersSidecarPath(filePath));
|
|
117
|
+
return (await this.linkIfAbsent(tempBlob, filePath)) ? 'created' : 'already-exists';
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await Promise.all([tempBlob, tempHeaders].map((temp) => fs.unlink(temp).catch(() => { })));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
81
123
|
async beginUpload(_input) {
|
|
82
124
|
return {
|
|
83
125
|
mode: 'proxied',
|
|
@@ -109,10 +151,11 @@ export class LocalAssetStore {
|
|
|
109
151
|
await this.deleteFileIfPresent(this.resolveKey(key));
|
|
110
152
|
}
|
|
111
153
|
async putOriginal(input) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
154
|
+
return this.createExclusive(originalKey(input.hash32, input.ext), input.data, {
|
|
155
|
+
contentType: input.contentType,
|
|
156
|
+
});
|
|
115
157
|
}
|
|
158
|
+
/** Ignores the `ext` hint: a directory read costs nothing here and needs no permission. */
|
|
116
159
|
async readOriginal(hash32) {
|
|
117
160
|
const dir = this.resolveKey(ASSET_PREFIXES.originals);
|
|
118
161
|
let entries;
|
|
@@ -125,7 +168,7 @@ export class LocalAssetStore {
|
|
|
125
168
|
throw err;
|
|
126
169
|
}
|
|
127
170
|
const prefix = `${hash32}.`;
|
|
128
|
-
const match = entries.find((name) => name.startsWith(prefix) &&
|
|
171
|
+
const match = entries.find((name) => name.startsWith(prefix) && ORIGINAL_EXT_RE.test(name.slice(prefix.length)));
|
|
129
172
|
if (!match)
|
|
130
173
|
return null;
|
|
131
174
|
const filePath = path.join(dir, match);
|
|
@@ -136,14 +179,20 @@ export class LocalAssetStore {
|
|
|
136
179
|
return { data, ext: match.slice(prefix.length), contentType: headers.contentType };
|
|
137
180
|
}
|
|
138
181
|
async putPublicObject(input) {
|
|
139
|
-
|
|
140
|
-
await this.writeFile(filePath, input.data);
|
|
141
|
-
await this.writeHeadersSidecar(filePath, {
|
|
182
|
+
return this.createExclusive(input.key, input.data, {
|
|
142
183
|
contentType: input.contentType,
|
|
143
184
|
contentDisposition: input.contentDisposition,
|
|
144
185
|
cacheControl: input.cacheControl,
|
|
145
186
|
});
|
|
146
187
|
}
|
|
188
|
+
async copyPublicObject(sourceKey, destKey) {
|
|
189
|
+
const sourcePath = this.resolveKey(sourceKey);
|
|
190
|
+
const data = await this.readFileOrNull(sourcePath);
|
|
191
|
+
if (!data)
|
|
192
|
+
return 'source-missing';
|
|
193
|
+
const { contentType, contentDisposition, cacheControl } = await this.readHeadersSidecar(sourcePath);
|
|
194
|
+
return this.createExclusive(destKey, data, { contentType, contentDisposition, cacheControl });
|
|
195
|
+
}
|
|
147
196
|
async readPublicObject(key) {
|
|
148
197
|
const filePath = this.resolveKey(key);
|
|
149
198
|
const data = await this.readFileOrNull(filePath);
|
|
@@ -157,6 +206,16 @@ export class LocalAssetStore {
|
|
|
157
206
|
cacheControl: headers.cacheControl,
|
|
158
207
|
};
|
|
159
208
|
}
|
|
209
|
+
async hasPublicObject(key) {
|
|
210
|
+
try {
|
|
211
|
+
return (await fs.stat(this.resolveKey(key))).isFile();
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
if (isNotFoundError(err))
|
|
215
|
+
return false;
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
160
219
|
/**
|
|
161
220
|
* Atomic-exclusive create: opens with the `wx` flag so two concurrent
|
|
162
221
|
* writers racing on the same hash32 have exactly one winner ('created')
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* S3-backed AssetStore. Same bucket-prefix layout as LocalAssetStore (see
|
|
3
3
|
* keys.ts). Assumes an EXISTING content bucket (versioning/SSE/replication
|
|
4
|
-
* already configured by the site's CDK stack)
|
|
5
|
-
*
|
|
4
|
+
* already configured by the site's CDK stack). It reads and writes under the
|
|
5
|
+
* five asset prefixes, plus a caller's output prefix for `copyPublicObject`.
|
|
6
6
|
*/
|
|
7
7
|
import { type AssetPrefixes } from './keys.js';
|
|
8
|
-
import type { AssetMeta, AssetStore, BeginUploadInput, PublicObject, StagedUploadTarget } from './types.js';
|
|
8
|
+
import type { AssetMeta, AssetStore, BeginUploadInput, CreateOnlyResult, PublicObject, StagedUploadTarget } from './types.js';
|
|
9
9
|
export interface S3AssetStoreOptions {
|
|
10
10
|
bucket: string;
|
|
11
11
|
region: string;
|
|
@@ -23,6 +23,8 @@ export interface S3AssetStoreOptions {
|
|
|
23
23
|
uploadUrl?: string;
|
|
24
24
|
/** Override the default bucket-prefix layout (rarely needed). */
|
|
25
25
|
prefixes?: AssetPrefixes;
|
|
26
|
+
/** @internal Test seam for the conditional-write retry delay. */
|
|
27
|
+
sleep?: (ms: number) => Promise<void>;
|
|
26
28
|
}
|
|
27
29
|
export declare class S3AssetStore implements AssetStore {
|
|
28
30
|
readonly capabilities: {
|
|
@@ -34,7 +36,19 @@ export declare class S3AssetStore implements AssetStore {
|
|
|
34
36
|
private readonly uploadUrl;
|
|
35
37
|
private readonly keys;
|
|
36
38
|
private readonly stagingPrefix;
|
|
39
|
+
private readonly sleep;
|
|
37
40
|
constructor(options: S3AssetStoreOptions);
|
|
41
|
+
/**
|
|
42
|
+
* Sends one create-only request (`IfNoneMatch: '*'`), so S3 answers a taken key with 412. A
|
|
43
|
+
* `ConditionalRequestConflict` is retried here, the only retry layer for it, so a caller with no
|
|
44
|
+
* retry of its own (the lazy transform Lambda, finalize) ends in one of the two results.
|
|
45
|
+
*
|
|
46
|
+
* With `retriedIsCreated`, a 412 after any error the SDK had already retried counts as
|
|
47
|
+
* `created`: one of those attempts may have committed and lost its response. Public objects use
|
|
48
|
+
* it; meta does not, since a meta `already-exists` sends finalize to read the stored winner.
|
|
49
|
+
*/
|
|
50
|
+
private createIfAbsent;
|
|
51
|
+
private putIfAbsent;
|
|
38
52
|
/**
|
|
39
53
|
* Staging methods accept caller-influenced keys (finalize receives the key
|
|
40
54
|
* from the client), so they must never operate outside the staging prefix —
|
|
@@ -59,8 +73,8 @@ export declare class S3AssetStore implements AssetStore {
|
|
|
59
73
|
ext: string;
|
|
60
74
|
data: Uint8Array;
|
|
61
75
|
contentType: string;
|
|
62
|
-
}): Promise<
|
|
63
|
-
readOriginal(hash32: string): Promise<{
|
|
76
|
+
}): Promise<CreateOnlyResult>;
|
|
77
|
+
readOriginal(hash32: string, ext?: string): Promise<{
|
|
64
78
|
data: Uint8Array;
|
|
65
79
|
ext: string;
|
|
66
80
|
contentType?: string;
|
|
@@ -71,9 +85,26 @@ export declare class S3AssetStore implements AssetStore {
|
|
|
71
85
|
contentType: string;
|
|
72
86
|
contentDisposition?: string;
|
|
73
87
|
cacheControl?: string;
|
|
74
|
-
|
|
88
|
+
tags?: Readonly<Record<string, string>>;
|
|
89
|
+
}): Promise<CreateOnlyResult>;
|
|
90
|
+
/**
|
|
91
|
+
* A server-side CopyObject. S3 URL-decodes `CopySource`, and keys hold `=`, `,` and `:`, so each
|
|
92
|
+
* segment is encoded and the `/` between them is not. `MetadataDirective: 'COPY'` keeps the
|
|
93
|
+
* source's Content-Type, Cache-Control and Content-Disposition; `TaggingDirective: 'REPLACE'`
|
|
94
|
+
* with no `Tagging` leaves the copy untagged and needs no tagging permission, even from a
|
|
95
|
+
* lazy-tagged source; `COPY` would need `s3:GetObjectTagging` and `s3:PutObjectTagging`.
|
|
96
|
+
*/
|
|
97
|
+
copyPublicObject(sourceKey: string, destKey: string): Promise<CreateOnlyResult | 'source-missing'>;
|
|
75
98
|
readPublicObject(key: string): Promise<PublicObject | null>;
|
|
76
|
-
|
|
99
|
+
/**
|
|
100
|
+
* A HEAD. S3 answers a missing key with 403, not 404, unless the caller may `s3:ListBucket`,
|
|
101
|
+
* so a role without it sees an error here rather than `false`.
|
|
102
|
+
*/
|
|
103
|
+
hasPublicObject(key: string): Promise<boolean>;
|
|
104
|
+
listPublicObjectKeys(prefix: string): AsyncIterable<string>;
|
|
105
|
+
/** HEADs first: signing is local and succeeds for a missing key too. */
|
|
106
|
+
presignPublicObjectRead(key: string): Promise<string | null>;
|
|
107
|
+
putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<CreateOnlyResult>;
|
|
77
108
|
getMeta(hash32: string): Promise<AssetMeta | null>;
|
|
78
109
|
listMeta(input?: {
|
|
79
110
|
cursor?: string;
|