@anthusai/papyrus 1.0.0-next.44 → 1.0.0-next.46

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.
@@ -6,7 +6,7 @@ from typing import Any
6
6
 
7
7
  from papyrus_content.graphql_authoring import create_authoring_client
8
8
  from papyrus_content.markus_renderer.derive import derive_body
9
- from papyrus_content.projection.engine import project
9
+ from papyrus_content.projection.engine import project, project_item_pages
10
10
  from papyrus_content.projection.runtime import (
11
11
  DEAD_LETTER_QUEUE_ENV,
12
12
  context_from_environment,
@@ -63,7 +63,14 @@ def derive_markus_action(arguments: dict) -> dict:
63
63
  return response
64
64
 
65
65
 
66
- def send_to_projection_dead_letter_queue(item_id: str, unpublished: bool, error: str) -> None:
66
+ PROJECTION_TASK_KEY = "projectionTask"
67
+
68
+
69
+ class ProjectionTaskFailed(Exception):
70
+ """Raised by an asynchronous projection task so Lambda retries it and, finally, sends it to the dead letter queue."""
71
+
72
+
73
+ def send_to_projection_dead_letter_queue(task: dict, error: str) -> None:
67
74
  queue_url = (os.environ.get(DEAD_LETTER_QUEUE_ENV) or "").strip()
68
75
  if not queue_url:
69
76
  return
@@ -71,23 +78,50 @@ def send_to_projection_dead_letter_queue(item_id: str, unpublished: bool, error:
71
78
  import boto3
72
79
 
73
80
  boto3.client("sqs").send_message(
74
- QueueUrl=queue_url,
75
- MessageBody=json.dumps({"itemId": item_id, "unpublished": unpublished, "error": error}),
81
+ QueueUrl=queue_url, MessageBody=json.dumps({PROJECTION_TASK_KEY: task, "error": error})
76
82
  )
77
83
  except Exception: # noqa: BLE001 - the dead letter is best effort; the response already reports the failure
78
84
  pass
79
85
 
80
86
 
87
+ def schedule_projection_task(task: dict, error: str) -> bool:
88
+ """Run a projection task later, in this same function, as an asynchronous invocation.
89
+
90
+ The function has an on-failure destination (the dead letter queue), so a task
91
+ that keeps failing ends up there for ``papyrus ops content project --reconcile``.
92
+ """
93
+ function_name = (os.environ.get("AWS_LAMBDA_FUNCTION_NAME") or "").strip()
94
+ if not function_name:
95
+ send_to_projection_dead_letter_queue(task, error)
96
+ return False
97
+ try:
98
+ import boto3
99
+
100
+ boto3.client("lambda").invoke(
101
+ FunctionName=function_name, InvocationType="Event", Payload=json.dumps({PROJECTION_TASK_KEY: task}).encode("utf-8")
102
+ )
103
+ return True
104
+ except Exception as scheduling_error: # noqa: BLE001 - fall back to the dead letter queue
105
+ send_to_projection_dead_letter_queue(task, f"{error}; scheduling failed: {scheduling_error}")
106
+ return False
107
+
108
+
109
+ def projection_context(client):
110
+ context = context_from_environment(client)
111
+ context.defer_follow_up = True
112
+ return context
113
+
114
+
81
115
  def project_item_action(client, item_id: str, *, unpublished: bool) -> dict:
82
116
  if not projection_configured():
83
117
  return {}
84
118
  liveness = {} if unpublished else {"live": False}
119
+ task = {"task": "item", "itemId": item_id, "unpublished": unpublished}
85
120
  try:
86
- context = context_from_environment(client)
87
- report = project(context, item_id, unpublished=unpublished)
121
+ report = project(projection_context(client), item_id, unpublished=unpublished)
88
122
  except Exception as error: # noqa: BLE001 - a projection failure never fails the save
89
123
  message = f"{type(error).__name__}: {error}"
90
- send_to_projection_dead_letter_queue(item_id, unpublished, message)
124
+ schedule_projection_task(task, message)
91
125
  return {**liveness, "projected": False, "paths": [], "error": message}
92
126
  response = {"projected": report.ok, "paths": [*report.written, *report.deleted]}
93
127
  if not unpublished:
@@ -97,11 +131,29 @@ def project_item_action(client, item_id: str, *, unpublished: bool) -> dict:
97
131
  response["url"] = url
98
132
  if not report.ok:
99
133
  message = "; ".join(report.errors)
100
- send_to_projection_dead_letter_queue(item_id, unpublished, message)
134
+ schedule_projection_task(task, message)
101
135
  response["error"] = message
136
+ if report.follow_up_item_pages:
137
+ schedule_projection_task({"task": "item-pages", "exceptItemId": item_id}, "follow-up")
102
138
  return response
103
139
 
104
140
 
141
+ def run_projection_task(task: dict) -> dict:
142
+ """An asynchronous projection task. Raising makes Lambda retry it, then send the event to the dead letter queue."""
143
+ client, _claims = create_authoring_client()
144
+ if task.get("task") == "item-pages":
145
+ report = project_item_pages(projection_context(client), except_item_id=task.get("exceptItemId"))
146
+ elif task.get("task") == "item":
147
+ report = project(projection_context(client), task["itemId"], unpublished=bool(task.get("unpublished")))
148
+ if report.follow_up_item_pages:
149
+ schedule_projection_task({"task": "item-pages", "exceptItemId": task["itemId"]}, "follow-up")
150
+ else:
151
+ raise ProjectionTaskFailed(f"Unknown projection task {task.get('task')!r}")
152
+ if not report.ok:
153
+ raise ProjectionTaskFailed("; ".join(report.errors))
154
+ return {"ok": True, "paths": [*report.written, *report.deleted]}
155
+
156
+
105
157
  def save_item_draft_action(arguments: dict, actor: str) -> dict:
106
158
  client, _claims = create_authoring_client()
107
159
  fields = ItemFields(
@@ -183,6 +235,8 @@ def request_staging_build_action() -> dict:
183
235
 
184
236
 
185
237
  def handler(event, context):
238
+ if PROJECTION_TASK_KEY in event:
239
+ return run_projection_task(event[PROJECTION_TASK_KEY])
186
240
  field = event["fieldName"]
187
241
  arguments = parse_input(event["arguments"]["input"])
188
242
  actor = actor_from_event(event)
@@ -1,5 +1,5 @@
1
1
  PyYAML>=6.0.2,<7
2
2
  citeproc-py>=0.8.2,<1
3
3
  anthus-markus==0.5.1
4
- Pillow>=10,<12
4
+ Pillow>=11.3,<12
5
5
  awscrt>=0.23,<1
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { pythonLambdaCode } from "../shared/python-bundle.js";
8
8
  const dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const projectRoot = path.resolve(dirname, "../../..");
10
- export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=10,<12", "awscrt>=0.23,<1"];
10
+ export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
11
11
  function installCheckoutRequirements(outputDir) {
12
12
  const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
13
13
  execSync(`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`, { cwd: projectRoot, stdio: "inherit" });
@@ -10,7 +10,7 @@ import { pythonLambdaCode, type SiteBundleFile } from "../shared/python-bundle";
10
10
  const dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const projectRoot = path.resolve(dirname, "../../..");
12
12
 
13
- export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=10,<12", "awscrt>=0.23,<1"];
13
+ export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
14
14
 
15
15
  function installCheckoutRequirements(outputDir: string): void {
16
16
  const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from papyrus_content.projection.runtime import SITE_BUCKET_ENV, SITE_STATIC_DIRECTORY_ENV
10
+ from papyrus_content.renditions import (
11
+ RenditionSettings,
12
+ S3RenditionStore,
13
+ make_renditions,
14
+ meta_key,
15
+ settings_from_static_directory,
16
+ source_from_media_key,
17
+ )
18
+
19
+ _loaded: dict[str, Any] = {}
20
+
21
+
22
+ def configured_settings() -> RenditionSettings | None:
23
+ if "settings" not in _loaded:
24
+ static_directory = (os.environ.get(SITE_STATIC_DIRECTORY_ENV) or "").strip()
25
+ _loaded["settings"] = settings_from_static_directory(Path(static_directory) if static_directory else None)
26
+ return _loaded["settings"]
27
+
28
+
29
+ def site_store() -> S3RenditionStore:
30
+ if "store" not in _loaded:
31
+ _loaded["store"] = S3RenditionStore(os.environ[SITE_BUCKET_ENV])
32
+ return _loaded["store"]
33
+
34
+
35
+ def s3_client():
36
+ if "s3" not in _loaded:
37
+ import boto3
38
+
39
+ _loaded["s3"] = boto3.client("s3")
40
+ return _loaded["s3"]
41
+
42
+
43
+ def media_objects(event: dict) -> list[tuple[str, str]]:
44
+ if event.get("source") == "aws.s3" and "detail" in event:
45
+ detail = event["detail"]
46
+ return [(detail["bucket"]["name"], detail["object"]["key"])]
47
+ if "bucket" in event and "key" in event:
48
+ return [(event["bucket"], event["key"])]
49
+ raise ValueError("The event is neither an S3 object event nor a {bucket, key} request.")
50
+
51
+
52
+ def render_media_object(
53
+ settings: RenditionSettings, bucket: str, key: str, *, skip_existing: bool = False, dry_run: bool = False
54
+ ) -> dict:
55
+ source = source_from_media_key(key)
56
+ if source is None:
57
+ return {"key": key, "action": "ignored", "reason": "not an image under media/"}
58
+ if skip_existing and site_store().get(meta_key(settings.pipeline, source)) is not None:
59
+ return {"key": key, "source": source, "action": "skipped-existing"}
60
+ if dry_run:
61
+ return {"key": key, "source": source, "action": "would-render"}
62
+ started = time.perf_counter()
63
+ data = s3_client().get_object(Bucket=bucket, Key=key)["Body"].read()
64
+ downloaded = time.perf_counter()
65
+ result = make_renditions(settings, site_store(), source, data)
66
+ finished = time.perf_counter()
67
+ return {
68
+ "key": key,
69
+ "source": source,
70
+ "action": "rendered",
71
+ "kind": result["kind"],
72
+ "written": result["written"],
73
+ "sourceBytes": len(data),
74
+ "downloadMilliseconds": round((downloaded - started) * 1000),
75
+ "renditionMilliseconds": round((finished - downloaded) * 1000),
76
+ }
77
+
78
+
79
+ def handler(event, context):
80
+ settings = configured_settings()
81
+ if settings is None:
82
+ return {"ok": True, "results": [], "skipped": "this site has no images settings in projection.json"}
83
+ results = [
84
+ render_media_object(
85
+ settings,
86
+ bucket,
87
+ key,
88
+ skip_existing=event.get("skipExisting") is True,
89
+ dry_run=event.get("dryRun") is True,
90
+ )
91
+ for bucket, key in media_objects(event)
92
+ ]
93
+ print(json.dumps({"renditions": results}))
94
+ return {"ok": True, "results": results}
@@ -0,0 +1,4 @@
1
+ PyYAML>=6.0.2,<7
2
+ citeproc-py>=0.8.2,<1
3
+ anthus-markus==0.5.1
4
+ Pillow>=11.3,<12
@@ -0,0 +1,74 @@
1
+ import { Duration, Size } from "aws-cdk-lib";
2
+ import * as events from "aws-cdk-lib/aws-events";
3
+ import * as targets from "aws-cdk-lib/aws-events-targets";
4
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
5
+ import { Architecture, Function as LambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
6
+ import * as sqs from "aws-cdk-lib/aws-sqs";
7
+ import { execSync } from "node:child_process";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { pythonLambdaCode } from "../functions/shared/python-bundle.js";
11
+ import { RENDITIONS_EPHEMERAL_STORAGE_MB, RENDITIONS_MEDIA_PREFIX, RENDITIONS_MEMORY_MB, RENDITIONS_TIMEOUT_SECONDS, renditionsEnvironment, renditionsEventPattern, } from "./renditions-settings.js";
12
+ const dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const projectRoot = path.resolve(dirname, "../..");
14
+ export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12"];
15
+ function installCheckoutRequirements(outputDir) {
16
+ const requirementsPath = path.join(projectRoot, "amplify/functions/renditions/requirements.txt");
17
+ execSync(`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`, { cwd: projectRoot, stdio: "inherit" });
18
+ }
19
+ export function addRenditionsFunction(scope, props) {
20
+ const deadLetterQueue = new sqs.Queue(scope, "RenditionsDeadLetterQueue", {
21
+ encryption: sqs.QueueEncryption.SQS_MANAGED,
22
+ retentionPeriod: Duration.days(14),
23
+ enforceSSL: true,
24
+ });
25
+ const renditions = new LambdaFunction(scope, "RenditionsFunction", {
26
+ runtime: Runtime.PYTHON_3_12,
27
+ architecture: Architecture.ARM_64,
28
+ handler: "handler.handler",
29
+ timeout: Duration.seconds(RENDITIONS_TIMEOUT_SECONDS),
30
+ memorySize: RENDITIONS_MEMORY_MB,
31
+ ephemeralStorageSize: Size.mebibytes(RENDITIONS_EPHEMERAL_STORAGE_MB),
32
+ retryAttempts: 2,
33
+ deadLetterQueue,
34
+ environment: renditionsEnvironment(props.siteBucket.bucketName),
35
+ code: props.code ?? pythonLambdaCode({
36
+ functionDir: "renditions",
37
+ modules: ["papyrus_content"],
38
+ requirements: renditionsRequirements,
39
+ noDeps: true,
40
+ checkoutExtras: installCheckoutRequirements,
41
+ siteFiles: props.siteFiles,
42
+ }, [
43
+ "bash",
44
+ "-c",
45
+ [
46
+ "set -euo pipefail",
47
+ "mkdir -p /asset-output/papyrus_content",
48
+ "cp amplify/functions/renditions/handler.py /asset-output/handler.py",
49
+ "cp -R src/papyrus_content/. /asset-output/papyrus_content/",
50
+ "python3 -m pip install -r amplify/functions/renditions/requirements.txt -t /asset-output --no-cache-dir",
51
+ ].join(" && "),
52
+ ]),
53
+ });
54
+ renditions.addToRolePolicy(new PolicyStatement({
55
+ effect: Effect.ALLOW,
56
+ actions: ["s3:GetObject"],
57
+ resources: [props.mediaBucket.arnForObjects(`${RENDITIONS_MEDIA_PREFIX}*`)],
58
+ }));
59
+ renditions.addToRolePolicy(new PolicyStatement({
60
+ effect: Effect.ALLOW,
61
+ actions: ["s3:PutObject", "s3:GetObject"],
62
+ resources: [props.siteBucket.arnForObjects("*")],
63
+ }));
64
+ renditions.addToRolePolicy(new PolicyStatement({
65
+ effect: Effect.ALLOW,
66
+ actions: ["s3:ListBucket"],
67
+ resources: [props.siteBucket.bucketArn],
68
+ }));
69
+ const rule = new events.Rule(scope, "MediaObjectCreatedRule", {
70
+ eventPattern: renditionsEventPattern(props.mediaBucket.bucketName),
71
+ });
72
+ rule.addTarget(new targets.LambdaFunction(renditions, { retryAttempts: 2 }));
73
+ return { function: renditions, rule, deadLetterQueue };
74
+ }
@@ -0,0 +1,112 @@
1
+ import { Duration, Size } from "aws-cdk-lib";
2
+ import * as events from "aws-cdk-lib/aws-events";
3
+ import * as targets from "aws-cdk-lib/aws-events-targets";
4
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
5
+ import { Architecture, type Code, Function as LambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
6
+ import * as s3 from "aws-cdk-lib/aws-s3";
7
+ import * as sqs from "aws-cdk-lib/aws-sqs";
8
+ import type { Construct } from "constructs";
9
+ import { execSync } from "node:child_process";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { pythonLambdaCode, type SiteBundleFile } from "../functions/shared/python-bundle";
13
+ import {
14
+ RENDITIONS_EPHEMERAL_STORAGE_MB,
15
+ RENDITIONS_MEDIA_PREFIX,
16
+ RENDITIONS_MEMORY_MB,
17
+ RENDITIONS_TIMEOUT_SECONDS,
18
+ renditionsEnvironment,
19
+ renditionsEventPattern,
20
+ } from "./renditions-settings";
21
+
22
+ const dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ const projectRoot = path.resolve(dirname, "../..");
24
+
25
+ export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12"];
26
+
27
+ export type RenditionsFunctionProps = {
28
+ siteBucket: s3.IBucket;
29
+ mediaBucket: s3.IBucket;
30
+ siteFiles: SiteBundleFile[];
31
+ code?: Code;
32
+ };
33
+
34
+ export type RenditionsFunction = {
35
+ function: LambdaFunction;
36
+ rule: events.Rule;
37
+ deadLetterQueue: sqs.Queue;
38
+ };
39
+
40
+ function installCheckoutRequirements(outputDir: string): void {
41
+ const requirementsPath = path.join(projectRoot, "amplify/functions/renditions/requirements.txt");
42
+ execSync(
43
+ `python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`,
44
+ { cwd: projectRoot, stdio: "inherit" },
45
+ );
46
+ }
47
+
48
+ export function addRenditionsFunction(scope: Construct, props: RenditionsFunctionProps): RenditionsFunction {
49
+ const deadLetterQueue = new sqs.Queue(scope, "RenditionsDeadLetterQueue", {
50
+ encryption: sqs.QueueEncryption.SQS_MANAGED,
51
+ retentionPeriod: Duration.days(14),
52
+ enforceSSL: true,
53
+ });
54
+ const renditions = new LambdaFunction(scope, "RenditionsFunction", {
55
+ runtime: Runtime.PYTHON_3_12,
56
+ architecture: Architecture.ARM_64,
57
+ handler: "handler.handler",
58
+ timeout: Duration.seconds(RENDITIONS_TIMEOUT_SECONDS),
59
+ memorySize: RENDITIONS_MEMORY_MB,
60
+ ephemeralStorageSize: Size.mebibytes(RENDITIONS_EPHEMERAL_STORAGE_MB),
61
+ retryAttempts: 2,
62
+ deadLetterQueue,
63
+ environment: renditionsEnvironment(props.siteBucket.bucketName),
64
+ code: props.code ?? pythonLambdaCode(
65
+ {
66
+ functionDir: "renditions",
67
+ modules: ["papyrus_content"],
68
+ requirements: renditionsRequirements,
69
+ noDeps: true,
70
+ checkoutExtras: installCheckoutRequirements,
71
+ siteFiles: props.siteFiles,
72
+ },
73
+ [
74
+ "bash",
75
+ "-c",
76
+ [
77
+ "set -euo pipefail",
78
+ "mkdir -p /asset-output/papyrus_content",
79
+ "cp amplify/functions/renditions/handler.py /asset-output/handler.py",
80
+ "cp -R src/papyrus_content/. /asset-output/papyrus_content/",
81
+ "python3 -m pip install -r amplify/functions/renditions/requirements.txt -t /asset-output --no-cache-dir",
82
+ ].join(" && "),
83
+ ],
84
+ ),
85
+ });
86
+ renditions.addToRolePolicy(
87
+ new PolicyStatement({
88
+ effect: Effect.ALLOW,
89
+ actions: ["s3:GetObject"],
90
+ resources: [props.mediaBucket.arnForObjects(`${RENDITIONS_MEDIA_PREFIX}*`)],
91
+ }),
92
+ );
93
+ renditions.addToRolePolicy(
94
+ new PolicyStatement({
95
+ effect: Effect.ALLOW,
96
+ actions: ["s3:PutObject", "s3:GetObject"],
97
+ resources: [props.siteBucket.arnForObjects("*")],
98
+ }),
99
+ );
100
+ renditions.addToRolePolicy(
101
+ new PolicyStatement({
102
+ effect: Effect.ALLOW,
103
+ actions: ["s3:ListBucket"],
104
+ resources: [props.siteBucket.bucketArn],
105
+ }),
106
+ );
107
+ const rule = new events.Rule(scope, "MediaObjectCreatedRule", {
108
+ eventPattern: renditionsEventPattern(props.mediaBucket.bucketName),
109
+ });
110
+ rule.addTarget(new targets.LambdaFunction(renditions, { retryAttempts: 2 }));
111
+ return { function: renditions, rule, deadLetterQueue };
112
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Pure planning for the upload-time renditions function of an s3-projection
3
+ * site (no CDK imports, so it can be tested without synthesizing).
4
+ *
5
+ * The function exists only for a site whose `projection.json` carries an
6
+ * `images` section: that section is the one place the image pipeline settings
7
+ * live, shared by the renditions function and the projector.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ export const RENDITIONS_MEMORY_MB = 3008;
12
+ export const RENDITIONS_TIMEOUT_SECONDS = 300;
13
+ export const RENDITIONS_EPHEMERAL_STORAGE_MB = 2048;
14
+ export const RENDITIONS_STATIC_DIRECTORY = "/var/task/site_static";
15
+ export const RENDITIONS_MEDIA_PREFIX = "media/";
16
+ export const RENDITIONS_FUNCTION_NAME_ENV = "PAPYRUS_RENDITIONS_FUNCTION_NAME";
17
+ export function projectionSettingsHaveImages(settingsPath) {
18
+ if (!fs.existsSync(settingsPath))
19
+ return false;
20
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
21
+ return typeof parsed.images === "object" && parsed.images !== null;
22
+ }
23
+ export function planRenditions(publication, workingDirectory) {
24
+ if (publication?.staticDir === undefined)
25
+ return { enabled: false, siteFiles: [] };
26
+ const settingsFile = path.join(publication.staticDir, "projection.json");
27
+ if (!projectionSettingsHaveImages(path.resolve(workingDirectory, settingsFile)))
28
+ return { enabled: false, siteFiles: [] };
29
+ return { enabled: true, siteFiles: [{ from: settingsFile, to: "site_static/projection.json" }] };
30
+ }
31
+ export function renditionsEventPattern(mediaBucketName) {
32
+ return {
33
+ source: ["aws.s3"],
34
+ detailType: ["Object Created"],
35
+ detail: { bucket: { name: [mediaBucketName] }, object: { key: [{ prefix: RENDITIONS_MEDIA_PREFIX }] } },
36
+ };
37
+ }
38
+ export function renditionsEnvironment(siteBucketName) {
39
+ return {
40
+ PAPYRUS_SITE_BUCKET: siteBucketName,
41
+ PAPYRUS_SITE_STATIC_DIR: RENDITIONS_STATIC_DIRECTORY,
42
+ };
43
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Pure planning for the upload-time renditions function of an s3-projection
3
+ * site (no CDK imports, so it can be tested without synthesizing).
4
+ *
5
+ * The function exists only for a site whose `projection.json` carries an
6
+ * `images` section: that section is the one place the image pipeline settings
7
+ * live, shared by the renditions function and the projector.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+
12
+ export const RENDITIONS_MEMORY_MB = 3008;
13
+ export const RENDITIONS_TIMEOUT_SECONDS = 300;
14
+ export const RENDITIONS_EPHEMERAL_STORAGE_MB = 2048;
15
+ export const RENDITIONS_STATIC_DIRECTORY = "/var/task/site_static";
16
+ export const RENDITIONS_MEDIA_PREFIX = "media/";
17
+ export const RENDITIONS_FUNCTION_NAME_ENV = "PAPYRUS_RENDITIONS_FUNCTION_NAME";
18
+
19
+ export type RenditionsPlan = {
20
+ enabled: boolean;
21
+ siteFiles: Array<{ from: string; to: string }>;
22
+ };
23
+
24
+ export function projectionSettingsHaveImages(settingsPath: string): boolean {
25
+ if (!fs.existsSync(settingsPath)) return false;
26
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as Record<string, unknown>;
27
+ return typeof parsed.images === "object" && parsed.images !== null;
28
+ }
29
+
30
+ export function planRenditions(publication: { staticDir?: string } | undefined, workingDirectory: string): RenditionsPlan {
31
+ if (publication?.staticDir === undefined) return { enabled: false, siteFiles: [] };
32
+ const settingsFile = path.join(publication.staticDir, "projection.json");
33
+ if (!projectionSettingsHaveImages(path.resolve(workingDirectory, settingsFile))) return { enabled: false, siteFiles: [] };
34
+ return { enabled: true, siteFiles: [{ from: settingsFile, to: "site_static/projection.json" }] };
35
+ }
36
+
37
+ export function renditionsEventPattern(mediaBucketName: string): Record<string, unknown> {
38
+ return {
39
+ source: ["aws.s3"],
40
+ detailType: ["Object Created"],
41
+ detail: { bucket: { name: [mediaBucketName] }, object: { key: [{ prefix: RENDITIONS_MEDIA_PREFIX }] } },
42
+ };
43
+ }
44
+
45
+ export function renditionsEnvironment(siteBucketName: string): Record<string, string> {
46
+ return {
47
+ PAPYRUS_SITE_BUCKET: siteBucketName,
48
+ PAPYRUS_SITE_STATIC_DIR: RENDITIONS_STATIC_DIRECTORY,
49
+ };
50
+ }
@@ -1,14 +1,17 @@
1
- import { Duration, RemovalPolicy } from "aws-cdk-lib";
1
+ import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
2
2
  import * as acm from "aws-cdk-lib/aws-certificatemanager";
3
3
  import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
4
4
  import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
5
- import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
5
+ import { Effect, Policy, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
6
+ import { SqsDestination } from "aws-cdk-lib/aws-lambda-destinations";
6
7
  import * as route53 from "aws-cdk-lib/aws-route53";
7
8
  import * as s3 from "aws-cdk-lib/aws-s3";
8
9
  import * as sqs from "aws-cdk-lib/aws-sqs";
9
10
  import { dirname, resolve } from "node:path";
10
11
  import { fileURLToPath } from "node:url";
11
12
  import { assertValidPublicationConfig } from "../site-backend-config.js";
13
+ import { addRenditionsFunction } from "./renditions-function.js";
14
+ import { RENDITIONS_FUNCTION_NAME_ENV } from "./renditions-settings.js";
12
15
  import { s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings.js";
13
16
  const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
14
17
  function createSiteBucket(scope, retainBucketOnDelete) {
@@ -60,7 +63,7 @@ function createDistribution(scope, props, bucket, redirectFunction) {
60
63
  ],
61
64
  });
62
65
  }
63
- export function addS3ProjectionPublication(scope, props) {
66
+ export function addS3ProjectionPublication(scope, props, media) {
64
67
  assertValidPublicationConfig({ kind: "s3-projection", ...props });
65
68
  const bucket = createSiteBucket(scope, props.retainBucketOnDelete !== false);
66
69
  const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
@@ -90,6 +93,9 @@ export function addS3ProjectionPublication(scope, props) {
90
93
  conditions: { StringEquals: { "aws:SourceArn": props.edgeDistributionArn } },
91
94
  }));
92
95
  }
96
+ const renditions = media?.renditions.enabled === true
97
+ ? addRenditionsFunction(scope, { siteBucket: bucket, mediaBucket: media.mediaBucket, siteFiles: media.renditions.siteFiles, code: media.renditionsCode })
98
+ : undefined;
93
99
  const settingsInput = {
94
100
  bucketName: bucket.bucketName,
95
101
  bucketArn: bucket.bucketArn,
@@ -111,7 +117,28 @@ export function addS3ProjectionPublication(scope, props) {
111
117
  deadLetterQueue,
112
118
  distribution,
113
119
  distributionId: distribution?.distributionId,
114
- environment: s3ProjectionEnvironment(settingsInput),
120
+ renditions,
121
+ environment: {
122
+ ...s3ProjectionEnvironment(settingsInput),
123
+ ...(renditions !== undefined ? { [RENDITIONS_FUNCTION_NAME_ENV]: renditions.function.functionName } : {}),
124
+ },
115
125
  policyStatements: s3ProjectionPolicyStatements(settingsInput).map((statement) => new PolicyStatement({ effect: Effect.ALLOW, actions: statement.actions, resources: statement.resources })),
116
126
  };
117
127
  }
128
+ /**
129
+ * The content-actions function runs a failed or follow-up projection again as an asynchronous
130
+ * invocation of itself: one retry, and an on-failure destination (the dead letter queue) for
131
+ * what still fails. The self-invoke permission is a separate policy on the role so that the
132
+ * function does not depend on a policy that refers to the function.
133
+ */
134
+ export function addProjectionRetries(lambda, deadLetterQueue) {
135
+ lambda.configureAsyncInvoke({
136
+ retryAttempts: 1,
137
+ maxEventAge: Duration.hours(1),
138
+ onFailure: new SqsDestination(deadLetterQueue),
139
+ });
140
+ new Policy(Stack.of(lambda), "ContentActionsProjectionSelfInvoke", {
141
+ statements: [new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [lambda.functionArn] })],
142
+ roles: [lambda.role],
143
+ });
144
+ }
@@ -1,8 +1,11 @@
1
- import { Duration, RemovalPolicy } from "aws-cdk-lib";
1
+ import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
2
2
  import * as acm from "aws-cdk-lib/aws-certificatemanager";
3
3
  import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
4
+ import type { Code } from "aws-cdk-lib/aws-lambda";
4
5
  import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
5
- import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
6
+ import { Effect, Policy, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
7
+ import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
8
+ import { SqsDestination } from "aws-cdk-lib/aws-lambda-destinations";
6
9
  import * as route53 from "aws-cdk-lib/aws-route53";
7
10
  import * as s3 from "aws-cdk-lib/aws-s3";
8
11
  import * as sqs from "aws-cdk-lib/aws-sqs";
@@ -10,12 +13,20 @@ import type { Construct } from "constructs";
10
13
  import { dirname, resolve } from "node:path";
11
14
  import { fileURLToPath } from "node:url";
12
15
  import { assertValidPublicationConfig, type PapyrusPublicationConfig } from "../site-backend-config";
16
+ import { addRenditionsFunction, type RenditionsFunction } from "./renditions-function";
17
+ import { RENDITIONS_FUNCTION_NAME_ENV, type RenditionsPlan } from "./renditions-settings";
13
18
  import { s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings";
14
19
 
15
20
  const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
16
21
 
17
22
  export type S3ProjectionPublicationProps = Omit<PapyrusPublicationConfig, "kind"> & { kind?: "s3-projection" };
18
23
 
24
+ export type S3ProjectionMedia = {
25
+ mediaBucket: s3.IBucket;
26
+ renditions: RenditionsPlan;
27
+ renditionsCode?: Code;
28
+ };
29
+
19
30
  export type S3ProjectionPublication = {
20
31
  bucket: s3.Bucket;
21
32
  keyValueStore: cloudfront.KeyValueStore;
@@ -23,6 +34,7 @@ export type S3ProjectionPublication = {
23
34
  deadLetterQueue: sqs.Queue;
24
35
  distribution?: cloudfront.Distribution;
25
36
  distributionId?: string;
37
+ renditions?: RenditionsFunction;
26
38
  environment: Record<string, string>;
27
39
  policyStatements: PolicyStatement[];
28
40
  };
@@ -88,7 +100,11 @@ function createDistribution(
88
100
  });
89
101
  }
90
102
 
91
- export function addS3ProjectionPublication(scope: Construct, props: S3ProjectionPublicationProps): S3ProjectionPublication {
103
+ export function addS3ProjectionPublication(
104
+ scope: Construct,
105
+ props: S3ProjectionPublicationProps,
106
+ media?: S3ProjectionMedia,
107
+ ): S3ProjectionPublication {
92
108
  assertValidPublicationConfig({ kind: "s3-projection", ...props });
93
109
  const bucket = createSiteBucket(scope, props.retainBucketOnDelete !== false);
94
110
 
@@ -123,6 +139,10 @@ export function addS3ProjectionPublication(scope: Construct, props: S3Projection
123
139
  );
124
140
  }
125
141
 
142
+ const renditions = media?.renditions.enabled === true
143
+ ? addRenditionsFunction(scope, { siteBucket: bucket, mediaBucket: media.mediaBucket, siteFiles: media.renditions.siteFiles, code: media.renditionsCode })
144
+ : undefined;
145
+
126
146
  const settingsInput = {
127
147
  bucketName: bucket.bucketName,
128
148
  bucketArn: bucket.bucketArn,
@@ -144,9 +164,31 @@ export function addS3ProjectionPublication(scope: Construct, props: S3Projection
144
164
  deadLetterQueue,
145
165
  distribution,
146
166
  distributionId: distribution?.distributionId,
147
- environment: s3ProjectionEnvironment(settingsInput),
167
+ renditions,
168
+ environment: {
169
+ ...s3ProjectionEnvironment(settingsInput),
170
+ ...(renditions !== undefined ? { [RENDITIONS_FUNCTION_NAME_ENV]: renditions.function.functionName } : {}),
171
+ },
148
172
  policyStatements: s3ProjectionPolicyStatements(settingsInput).map(
149
173
  (statement) => new PolicyStatement({ effect: Effect.ALLOW, actions: statement.actions, resources: statement.resources }),
150
174
  ),
151
175
  };
152
176
  }
177
+
178
+ /**
179
+ * The content-actions function runs a failed or follow-up projection again as an asynchronous
180
+ * invocation of itself: one retry, and an on-failure destination (the dead letter queue) for
181
+ * what still fails. The self-invoke permission is a separate policy on the role so that the
182
+ * function does not depend on a policy that refers to the function.
183
+ */
184
+ export function addProjectionRetries(lambda: LambdaFunction, deadLetterQueue: sqs.IQueue): void {
185
+ lambda.configureAsyncInvoke({
186
+ retryAttempts: 1,
187
+ maxEventAge: Duration.hours(1),
188
+ onFailure: new SqsDestination(deadLetterQueue),
189
+ });
190
+ new Policy(Stack.of(lambda), "ContentActionsProjectionSelfInvoke", {
191
+ statements: [new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [lambda.functionArn] })],
192
+ roles: [lambda.role!],
193
+ });
194
+ }
@@ -7,7 +7,8 @@ import { CfnIndex, CfnVectorBucket, CfnVectorBucketPolicy } from "aws-cdk-lib/aw
7
7
  import { dirname, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves.js";
10
- import { addS3ProjectionPublication } from "./publication/s3-projection.js";
10
+ import { addProjectionRetries, addS3ProjectionPublication } from "./publication/s3-projection.js";
11
+ import { planRenditions } from "./publication/renditions-settings.js";
11
12
  import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings.js";
12
13
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings.js";
13
14
  import { assertValidPublicationConfig } from "./site-backend-config.js";
@@ -68,6 +69,7 @@ export function defineSiteBackend(papyrusSite) {
68
69
  if (site.publication !== undefined)
69
70
  assertValidPublicationConfig(site.publication);
70
71
  const publicationPlan = planS3ProjectionBackend(site.publication);
72
+ const renditionsPlan = planRenditions(site.publication, process.cwd());
71
73
  setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
72
74
  const backend = defineBackend({
73
75
  assignmentAction,
@@ -224,8 +226,8 @@ export function defineSiteBackend(papyrusSite) {
224
226
  let storageBackupPlan;
225
227
  let storageBackupVault;
226
228
  const storageBucketCfn = storageBucket.node.defaultChild;
227
- if ((enableStorageBackups || enableInboundEmail) && storageBucketCfn) {
228
- // Backups and inbound-email intake both rely on S3 → EventBridge notifications.
229
+ if ((enableStorageBackups || enableInboundEmail || renditionsPlan.enabled) && storageBucketCfn) {
230
+ // Backups, inbound-email intake and upload-time renditions all rely on S3 → EventBridge notifications.
229
231
  storageBucketCfn.addPropertyOverride("NotificationConfiguration.EventBridgeConfiguration.EventBridgeEnabled", true);
230
232
  }
231
233
  if (enableStorageBackups) {
@@ -311,9 +313,15 @@ export function defineSiteBackend(papyrusSite) {
311
313
  }
312
314
  let s3ProjectionPublication;
313
315
  if (site.publication !== undefined) {
314
- s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication);
316
+ s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication, {
317
+ mediaBucket: storageBucket,
318
+ renditions: renditionsPlan,
319
+ });
315
320
  contentActionsLambda.node.defaultChild.memorySize = publicationPlan.contentActionsMemorySize;
316
321
  }
322
+ if (s3ProjectionPublication !== undefined) {
323
+ addProjectionRetries(contentActionsLambda, s3ProjectionPublication.deadLetterQueue);
324
+ }
317
325
  const publicationWiring = contentActionsPublicationWiring(s3ProjectionPublication);
318
326
  for (const [name, value] of Object.entries(publicationWiring.environment)) {
319
327
  contentActionsLambda.addEnvironment(name, value);
@@ -10,7 +10,8 @@ import type { Construct } from "constructs";
10
10
  import { dirname, resolve } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves";
13
- import { addS3ProjectionPublication, type S3ProjectionPublication } from "./publication/s3-projection";
13
+ import { addProjectionRetries, addS3ProjectionPublication, type S3ProjectionPublication } from "./publication/s3-projection";
14
+ import { planRenditions } from "./publication/renditions-settings";
14
15
  import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings";
15
16
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings";
16
17
  import { assertValidPublicationConfig } from "./site-backend-config";
@@ -85,6 +86,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
85
86
  };
86
87
  if (site.publication !== undefined) assertValidPublicationConfig(site.publication);
87
88
  const publicationPlan = planS3ProjectionBackend(site.publication);
89
+ const renditionsPlan = planRenditions(site.publication, process.cwd());
88
90
  setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
89
91
  const backend = defineBackend({
90
92
  assignmentAction,
@@ -285,8 +287,8 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
285
287
  let storageBackupVault: backup.BackupVault | undefined;
286
288
 
287
289
  const storageBucketCfn = storageBucket.node.defaultChild as s3.CfnBucket | undefined;
288
- if ((enableStorageBackups || enableInboundEmail) && storageBucketCfn) {
289
- // Backups and inbound-email intake both rely on S3 → EventBridge notifications.
290
+ if ((enableStorageBackups || enableInboundEmail || renditionsPlan.enabled) && storageBucketCfn) {
291
+ // Backups, inbound-email intake and upload-time renditions all rely on S3 → EventBridge notifications.
290
292
  storageBucketCfn.addPropertyOverride(
291
293
  "NotificationConfiguration.EventBridgeConfiguration.EventBridgeEnabled",
292
294
  true,
@@ -396,9 +398,15 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
396
398
  }
397
399
  let s3ProjectionPublication: S3ProjectionPublication | undefined;
398
400
  if (site.publication !== undefined) {
399
- s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication);
401
+ s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication, {
402
+ mediaBucket: storageBucket,
403
+ renditions: renditionsPlan,
404
+ });
400
405
  (contentActionsLambda.node.defaultChild as CfnFunction).memorySize = publicationPlan.contentActionsMemorySize;
401
406
  }
407
+ if (s3ProjectionPublication !== undefined) {
408
+ addProjectionRetries(contentActionsLambda, s3ProjectionPublication.deadLetterQueue);
409
+ }
402
410
  const publicationWiring = contentActionsPublicationWiring(s3ProjectionPublication);
403
411
  for (const [name, value] of Object.entries(publicationWiring.environment)) {
404
412
  contentActionsLambda.addEnvironment(name, value);
package/package.json CHANGED
@@ -302,7 +302,7 @@
302
302
  "optional": true
303
303
  }
304
304
  },
305
- "version": "1.0.0-next.44",
305
+ "version": "1.0.0-next.46",
306
306
  "dependencies": {
307
307
  "@aws-amplify/adapter-nextjs": "^1.7.3",
308
308
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.44",
2
+ "version": "1.0.0-next.46",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",