<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Well-Architected]]></title><description><![CDATA[Deep dives into building resilient, scalable and sustainable systems. Well-Architected explores the modern web and robust AWS infrastructure, focusing on best p]]></description><link>https://blog.hazya.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1771109717685/b6bfb15b-a95a-4cc7-bb8a-724077e517aa.png</url><title>Well-Architected</title><link>https://blog.hazya.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 19 May 2026 03:51:49 GMT</lastBuildDate><atom:link href="https://blog.hazya.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Why I Swapped semantic-release for release-please]]></title><description><![CDATA[Automating the release process is one of those "set it and forget it" tasks that pays dividends for years. Traditionally, semantic-release was the undisputed king of this domain. It’s powerful, strict]]></description><link>https://blog.hazya.dev/why-i-swapped-semantic-release-for-release-please</link><guid isPermaLink="true">https://blog.hazya.dev/why-i-swapped-semantic-release-for-release-please</guid><category><![CDATA[Devops]]></category><category><![CDATA[GitHub Actions]]></category><category><![CDATA[automation]]></category><category><![CDATA[ci-cd]]></category><category><![CDATA[Software Engineering]]></category><dc:creator><![CDATA[Hasitha Wickramasinghe]]></dc:creator><pubDate>Tue, 12 May 2026 12:20:14 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/fae28e4a-b23f-4441-b8bd-c4b7f735d1e7.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Automating the release process is one of those "set it and forget it" tasks that pays dividends for years. Traditionally, <code>semantic-release</code> was the undisputed king of this domain. It’s powerful, strictly follows Conventional Commits, and handles everything from versioning to publishing.</p>
<p>However, as my projects grew and my release workflows became more nuanced, I found myself looking for an alternative. That’s when I considered giving <strong>release-please</strong> a try.</p>
<p>In this article, I’ll walk you through why I made the switch, the architectural differences between these two giants, and how you can set up <code>release-please</code> in your own projects today.</p>
<h2>The Core Philosophy: "Release on Push" vs. "Release on Merge"</h2>
<p>The biggest differentiator between <code>semantic-release</code> and <code>release-please</code> is <em>when</em> the release actually happens.</p>
<h3>semantic-release: The Continuous Delivery Purist</h3>
<p><code>semantic-release</code> is built on the principle of continuous delivery. Every time you push a commit to your main branch that triggers a "release" (like a <code>feat:</code> or <code>fix:</code>), it immediately creates a tag, generates a changelog, and publishes the package.</p>
<p>While this is great for some projects, it can be aggressive. If you push five small fixes in an hour, you get five new versions.</p>
<h3>release-please: The Release PR Approach</h3>
<p><code>release-please</code> (developed by Google) takes a different approach. Instead of releasing immediately, it maintains a <strong>Release PR</strong>.</p>
<ol>
<li><p><strong>The Living Changelog:</strong> Every time you push a change to <code>main</code>, <code>release-please</code> updates a dedicated "Release PR". This PR contains the updated <code>CHANGELOG.md</code> and bumps the version in your <code>package.json</code>.</p>
</li>
<li><p><strong>The "Merge to Release" Trigger:</strong> You can see exactly what will be in the next release. When you're ready to ship, you simply merge that Release PR. <em>That</em> merge event is what triggers the actual tagging and publishing.</p>
</li>
</ol>
<p><strong>This is the main reason I prefer it.</strong> It gives me a "draft" state for my release. I can merge and release at any time, rather than being at the mercy of every single push.</p>
<h2>Why release-please Wins for Me</h2>
<h3>1. Manual Version Overrides</h3>
<p>Sometimes, the automated logic gets it wrong, or you want to force a specific version for marketing reasons. <code>release-please</code> allows you to manually set the version number in the Release PR or via configuration, which is significantly harder to do with the "black box" approach of <code>semantic-release</code>.</p>
<h3>2. Multi-Language Support</h3>
<p><code>semantic-release</code> is heavily rooted in the Node.js ecosystem. While plugins exist for other languages, they often feel like second-class citizens. <code>release-please</code> supports a massive array of languages out of the box, including:</p>
<ul>
<li><p>Rust</p>
</li>
<li><p>Python</p>
</li>
<li><p>Go</p>
</li>
<li><p>Java</p>
</li>
<li><p>PHP</p>
</li>
<li><p>Ruby</p>
</li>
</ul>
<p>If you’re managing a polyglot monorepo, <code>release-please</code> is a breath of fresh air.</p>
<h3>3. Automated Labels and Tags</h3>
<p><code>release-please</code> automatically handles GitHub labels and tags with precision. It keeps your PRs organized and your git history clean without needing a dozen different plugins.</p>
<h2>Deep Dive: Setting Up release-please</h2>
<p>Let's look at a real-world configuration. In a typical setup, you'll need three things: a GitHub Action, a config file, and a manifest file.</p>
<h3>1. The GitHub Action (<code>.github/workflows/release-please.yml</code>)</h3>
<p>This workflow runs on every push to <code>main</code>. It handles both the creation of the Release PR and the subsequent release when that PR is merged.</p>
<pre><code class="language-yaml">name: release-please

on:
  push:
    branches:
      - main

permissions:
  contents: write
  pull-requests: write

jobs:
  release-please:
    runs-on: ubuntu-latest
    steps:
      - uses: googleapis/release-please-action@v4
        id: release
        with:
          # Use a GitHub Token with repo scope
          token: ${{ secrets.MY_RELEASE_TOKEN }}
          config-file: release-please-config.json
          manifest-file: .release-please-manifest.json

      # This job only runs if a release was actually created (PR merged)
      - name: Publish to NPM
        if: ${{ steps.release.outputs.releases_created }}
        run: |
          npm install
          npm run build
          npm publish
</code></pre>
<h3>2. The Configuration (<code>release-please-config.json</code>)</h3>
<p>This file tells <code>release-please</code> which packages to track and how to format the changelog.</p>
<pre><code class="language-json">{
  "packages": {
    ".": {
      "release-type": "node",
      "package-name": "my-awesome-lib"
    }
  },
  "changelog-sections": [
    { "type": "feat", "section": "Features" },
    { "type": "fix", "section": "Bug Fixes" },
    { "type": "chore", "section": "Miscellaneous" }
  ],
  "plugins": [
    { "type": "node-workspace" }
  ]
}
</code></pre>
<h3>3. The Manifest (<code>.release-please-manifest.json</code>)</h3>
<p>This file tracks the current version of each package. <code>release-please</code> updates this automatically.</p>
<pre><code class="language-json">{
  ".": "1.2.0"
}
</code></pre>
<h2>Tips for Success</h2>
<ol>
<li><p><strong>Conventional Commits are Mandatory:</strong> None of this works if you don't use <code>feat:</code>, <code>fix:</code>, or <code>feat!:</code>. If you're new to this, I recommend using a tool like <code>commitlint</code> to enforce it.</p>
</li>
<li><p><strong>Bootstrap Your First Release:</strong> When you first add <code>release-please</code> to an existing project, you might need to tell it where to start. You can do this by adding a <code>bootstrap-sha</code> to your config file, which points to the commit where you want the automation to begin.</p>
</li>
<li><p><strong>Monorepo Power:</strong> If you use npm/yarn/pnpm workspaces, the <code>node-workspace</code> plugin (shown in the config above) is a lifesaver. It handles cross-package dependency updates automatically when one package in the monorepo is bumped.</p>
</li>
</ol>
<h2>Conclusion</h2>
<p>Switching to <code>release-please</code> moved my release process from "anxious automation" to "controlled automation." The ability to see my changelog grow in a Release PR <em>before</em> it hits the registry has saved me from countless "patch for a patch" scenarios.</p>
<p>If you’re looking for a release tool that respects your workflow and supports your growth across multiple languages, give <code>release-please</code> a try.</p>
]]></content:encoded></item><item><title><![CDATA[When AI-Written Code Becomes the Death of Deep Understanding]]></title><description><![CDATA[As a developer who uses AI coding tools every day and as an AWS Certified Generative AI Developer Professional, I am fundamentally optimistic about the future of AI-assisted development. However, we n]]></description><link>https://blog.hazya.dev/the-vibe-review-trap-when-ai-written-code-becomes-the-death-of-deep-understanding</link><guid isPermaLink="true">https://blog.hazya.dev/the-vibe-review-trap-when-ai-written-code-becomes-the-death-of-deep-understanding</guid><category><![CDATA[AI]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[code review]]></category><category><![CDATA[technical-debt]]></category><category><![CDATA[agentic-engineering]]></category><dc:creator><![CDATA[Hasitha Wickramasinghe]]></dc:creator><pubDate>Sat, 02 May 2026 06:47:20 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/1a765ee4-e357-497e-828d-e7118325de88.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>As a developer who uses AI coding tools every day and as an AWS Certified Generative AI Developer Professional, I am fundamentally optimistic about the future of AI-assisted development. However, we need to talk about the dangerous habits forming in the industry. We are rapidly approaching a "vibe-based" development lifecycle that threatens to turn our codebases into unmaintainable nightmares.</p>
<h2>The "Vibe Reviewing" Loop</h2>
<p>It starts with a prompt. You fire up your AI coding tool and ask a powerful model to implement a feature or fix a bug. The AI focuses solely on fulfilling your request; thus, it generates code and tests that get the job done. While you may have tried to steer it with rules and a "better-engineered" prompt, at the end of the day, to the AI model, your request is the one and only objective and getting the job done is all that matters. You push the code, open a PR, often with a rocket emoji in the PR description because even the PR is generated by an AI 🚀. And now you wait.</p>
<p>The reviewers, overwhelmed by the volume of code, also turn to AI. Depending on the "vibe" of the selected model for review and the configured rules, it either nitpicks every line or gives a hollow "LGTM" with a thumbs up 👍. If it nitpicks, you simply feed the feedback back into your AI agents until the yelling stops. Eventually, both parties get exhausted and merge something that somehow passes CI.</p>
<p>We've moved from code review to "vibe review". If it looks okay and the tests pass, we trust the "vibe".</p>
<h2>The Death of Deep Understanding</h2>
<p>In the pre-AI coding era, working on a task required a deep dive. You had to understand the "why" before the "how." You'd try different ideas, fail, hack something together, and eventually arrive at a clean solution through trial and error.</p>
<p>This struggle wasn't wasted time; it was the process of learning the nuances of the codebase. By the time you opened a PR, you <em>owned</em> that implementation.</p>
<p>Today, even a junior developer can fire up a Claude code session and ask for a solution with "no mistakes". It often works, but at what cost? Who owns that code now? Does the developer truly understand the trade-offs made, or are they just the messenger for a black box?</p>
<h2>The Literalism of AI Agents</h2>
<p>AI agents are remarkably literal. Because many models are tuned for precision, often with low <code>Temperature</code> and <code>TopP</code> settings, they tend to follow instructions to the letter while missing the obvious context. A model capable of identifying a zero-day vulnerability in a large codebase might still fail a basic logic test. The infamous "Car Wash Test" where you ask a state-of-the-art model like Claude Opus 4.7, "The car wash is only 50m away; should I walk or drive?" it will logically conclude you should walk, completely missing the point that you're likely taking a car to be washed.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/9b3864da-7b26-458c-8f43-aa3537a541b6.webp" alt="Claude Opus 4.7 car wash test failure." style="display:block;margin:0 auto" />

<p>Fundamentally, these models are token-crunching machines that are really good at finding the next best word. The AI agents are an extension of that; simply put, <em>they want to get the job done,</em> sometimes taking the <em>lazy</em> way out in doing so. They rarely push back on the "means". What you ask is what you get. That sounds like a good thing, but it means that if you ask for a specific implementation, they will happily provide it, even if it involves janky hacks, complex regex, or conditional pattern matching that a human would immediately flag as a red flag.</p>
<p>If you read the "thinking" monologues of these agents, it'll crack you up sometimes seeing them realize a request is suboptimal or even straight up "stupid," yet they proceed anyway because that was the instruction. Left to their own devices, an agent might consider forking Chromium to fix your React button if it thinks that's the most direct path to the goal you've given it.</p>
<h2>Technical Debt and the "Right" Line of Code</h2>
<p>Every line of code is a future liability. The goal of software engineering has always been to solve problems with as little code as possible. AI has drastically reduced the cost of writing a line of code, but the cost of the <em>right</em> line of code, the one that is maintainable, idiomatic, and necessary, remains as high as ever. Understanding <em>why</em> something is done in a certain way is ever more important.</p>
<p>I've seen AI agents introduce hundreds of lines of code into a monorepo to bypass a restriction that could have been solved with a three-line change in a shared package. Without "hand-holding" and deep architectural context, AI tends to add complexity rather than reduce it. This is precisely the reason why you should review everything your AI agents generate instead of giving in to the "vibe" and accepting every change just because it seems to work.</p>
<h2>The Open Source Warning</h2>
<p>We have been seeing the consequences in the open-source world for quite some time.</p>
<ul>
<li><p><strong>OCaml maintainers</strong> had to reject a 13,000-line AI-generated PR, noting that reviewing AI code is more taxing than human code and creates a risk of bringing the Pull Request system to a halt. <a href="https://github.com/ocaml/ocaml/blob/trunk/AI.md">OCaml AI.md</a></p>
</li>
<li><p><strong>Anthony Fu</strong> (Vue ecosystem) and others have reported being flooded with low-effort PRs where contributors simply loop through review comments using AI without ever understanding the underlying issues. <a href="https://nuxt.com/docs/4.x/community/contribution#ai-assisted-contributions">Nuxt AI-assisted Contributions</a></p>
</li>
</ul>
<h2>How to Avoid the Trap</h2>
<p>As <strong>Andrej Karpathy</strong> puts it,</p>
<blockquote>
<p><strong>Agentic Engineering</strong> is about improving productivity while preserving the quality bar of what existed before in professional software. You're still responsible for your software just as before.</p>
</blockquote>
<p>To keep your codebase from becoming a collection of "vibes," consider these practices to maintain the quality bar without adding to technical debt:</p>
<ul>
<li><p><strong>Demand Intentionality:</strong> Require authors to explain the <em>design decisions</em>, not just describe the diff.</p>
</li>
<li><p><strong>Review for Necessity:</strong> Always ask: "Could this problem be solved with less code?"</p>
</li>
<li><p><strong>Seek Missing Context:</strong> Identify the architectural constraints or shared utilities the AI might have missed.</p>
</li>
<li><p><strong>Question the Tests:</strong> Treat AI-generated tests with suspicion if they only mirror the implementation rather than challenging it.</p>
</li>
<li><p><strong>Enforce Ownership:</strong> Ensure the human author, not the agent, is ultimately responsible for every trade-off and side effect.</p>
</li>
</ul>
<h2>Conclusion: Beyond the Vibe</h2>
<p>If we continue to merge PRs that remain "black boxes" to both author and reviewer, we will eventually lose our collective understanding of how our systems actually work. "Tribal knowledge" will decay, and we’ll be left with a codebase no one truly owns.</p>
<p>AI is a powerful co-pilot, but it cannot replace the responsibility of an engineer. Our goal should always be less code, lower complexity, and deeper comprehension. Before you merge that AI-generated block, ask yourself: <em>Could I explain this to a colleague, or am I just trusting the vibe?</em></p>
<blockquote>
<p>You can outsource your thinking, but never your understanding.</p>
</blockquote>
<p>The true measure of a developer isn't how fast they can generate lines of code, it's how effectively they can solve problems without creating future liabilities. In the age of AI, understanding the "why" isn't just a best practice; it's our only defense against unmaintainable complexity.</p>
]]></content:encoded></item><item><title><![CDATA[Amazon S3 Files for Stateful Containers]]></title><description><![CDATA[Deploying stateless containers on AWS is a straightforward process, but deploying stateful applications on serverless containers has always been a bit of a "choose your own adventure" when it comes to]]></description><link>https://blog.hazya.dev/amazon-s3-files-for-stateful-containers</link><guid isPermaLink="true">https://blog.hazya.dev/amazon-s3-files-for-stateful-containers</guid><category><![CDATA[AWS]]></category><category><![CDATA[CloudComputing]]></category><category><![CDATA[serverless]]></category><category><![CDATA[WordPress]]></category><category><![CDATA[aws-cdk]]></category><dc:creator><![CDATA[Hasitha Wickramasinghe]]></dc:creator><pubDate>Sun, 19 Apr 2026 10:55:13 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/70e948f7-16ac-4fad-b67b-f16378adc1df.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Deploying stateless containers on AWS is a straightforward process, but deploying stateful applications on serverless containers has always been a bit of a "choose your own adventure" when it comes to storage. That's especially true for CMS applications like WordPress and Strapi, or any application that expects a writable shared filesystem. The database is only part of the story; user uploads, plugins, themes, and caches still need somewhere persistent to live.</p>
<p>Historically, that usually pushed you toward Amazon EFS, or toward application-specific workarounds like uploading media directly to S3 through a plugin. Both approaches can work, but they each come with tradeoffs.</p>
<p>Recently, AWS introduced <strong>Amazon S3 Files</strong>, a feature that allows you to mount S3 buckets as local file systems. In this post, we'll dive into a demo project that wires this up using the AWS CDK, using WordPress as an example workload.</p>
<blockquote>
<p><strong>Tip:</strong> While this demo uses WordPress, the strategy applies to any application requiring persistent, shared storage, from content management systems to data processing pipelines.</p>
</blockquote>
<h2>Why this Matters</h2>
<p>Containers on Fargate are ephemeral by design. If a task is replaced, anything written to the container's local filesystem disappears. That's fine for APIs and workers that keep all state in external services. It's not fine for platforms that write important data to disk.</p>
<p>WordPress is notoriously stateful. While the database handles your posts and metadata, the filesystem handles:</p>
<ul>
<li><p><strong>Media Uploads:</strong> Images and videos stored in <code>wp-content/uploads</code>.</p>
</li>
<li><p><strong>Customizations:</strong> Plugins and themes installed via the admin dashboard.</p>
</li>
</ul>
<p>In a traditional Fargate deployment, these files are ephemeral. If your container restarts or scales out, your data disappears.</p>
<p>The exact same problem shows up in self-hosted headless CMS platforms like Strapi, internal admin tools, ML workflows that generate artifacts, and legacy applications that assume a shared writable directory exists.</p>
<h2>What Amazon S3 Files Changes</h2>
<p><a href="https://aws.amazon.com/s3/features/files">Amazon S3 Files</a> provides a middle ground between the massive scale of S3 and the POSIX-compliant interface required by apps like WordPress.</p>
<p>The "secret sauce" here is that <strong>S3 Files is built using Amazon EFS</strong>. It acts as an intelligent cache layer that loads your active working set onto high-performance EFS storage for low latency, while the authoritative source of truth remains your S3 bucket.</p>
<p>When you read a file, it's lazily loaded from S3 into this cache. When you write, the data is saved to the high-performance layer and then synchronized back to S3. This means you get the performance of a real filesystem with the durability and ecosystem of S3.</p>
<h2>The Architecture in this Demo</h2>
<p>Instead of redesigning the application to speak directly to S3, we mount storage into the container and keep the application model unchanged. The Fargate tasks mount S3 Files at <code>/bitnami/wordpress</code>, which is where the Bitnami WordPress image expects its data.</p>
<p>The demo project provisions a robust infrastructure through five CDK stacks:</p>
<ul>
<li><p><code>VPC</code>: A VPC with public, private, and isolated subnets. It uses <strong>VPC Endpoints</strong> (S3, ECR, Secrets Manager) instead of NAT Gateways to keep service-to-service traffic entirely within the AWS network.</p>
</li>
<li><p><code>Database</code>: A MariaDB instance managed by RDS, tucked away in isolated subnets with credentials in Secrets Manager.</p>
</li>
<li><p><code>S3Files</code>: An S3 bucket backed by an S3 Files file system, access point, and mount targets.</p>
</li>
<li><p><code>ECR</code>: A private ECR repository seeded with the <a href="https://gallery.ecr.aws/bitnami/wordpress">Bitnami WordPress Image</a>.</p>
</li>
<li><p><code>App</code>: An <code>ApplicationLoadBalancedFargateService</code> running two WordPress tasks mounted to <code>S3Files</code>.</p>
</li>
</ul>
<p>Here is how the stacks and resources connect to each other:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/66d69f2f-527d-497f-bc14-bba6803d56b4.png" alt="AWS ECS Fargate Architecture with Amazon S3 Files." style="display:block;margin:0 auto" />

<p>At the stack dependency level, <code>App</code> depends on <code>VPC</code>, <code>Database</code>, <code>S3Files</code>, and <code>ECR</code>. At runtime, the WordPress tasks pull the container image from ECR, read credentials from Secrets Manager, connect to MariaDB, and mount the S3 Files volume through the access point and mount targets.</p>
<p>The important part is the storage path:</p>
<ol>
<li><p>WordPress runs on ECS Fargate.</p>
</li>
<li><p>The task mounts an <code>S3 Files</code> volume.</p>
</li>
<li><p>That volume maps to an S3-backed file system.</p>
</li>
<li><p>The application writes to <code>/bitnami/wordpress</code> as if it were local persistent storage.</p>
</li>
</ol>
<p>This means uploads, plugins, themes, and similar filesystem state survive task replacement and can be shared across tasks.</p>
<blockquote>
<p><strong>Complete Source Code:</strong> You can find the full implementation, including all the CDK stacks, in the <a href="https://github.com/haZya/s3-files-demo-with-wp">GitHub Repository</a>.</p>
</blockquote>
<h2>Implementing with CDK</h2>
<p>Since S3 Files is a relatively new feature, the AWS CDK currently supports it primarily through <strong>L1 constructs</strong> (the low-level <code>Cfn</code> resources auto-generated from the CloudFormation schemas).</p>
<p>In our <code>S3FilesStack</code>, we define the <code>CfnFileSystem</code>, <code>CfnAccessPoint</code>, and <code>CfnMountTarget</code>. These are then wired into the ECS Task Definition in the <code>AppStack</code> using a bit of "escape hatch" patching to configure the <code>s3FilesVolumeConfiguration</code>.</p>
<p>The implementation breaks down into four practical steps.</p>
<h3>1. Create the Backing Bucket and S3 Files Resources</h3>
<p>In <code>lib/s3-files-stack.ts</code>, the stack creates a versioned S3 bucket and then wires that bucket into <code>aws-s3files</code> resources. We define the File System, the Access Point (defining how users/groups interact with the files):</p>
<pre><code class="language-ts">// From lib/s3-files-stack.ts
this.s3FilesBucket = new Bucket(this, 'AppFilesBucket', {
  versioned: true,
  blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
  removalPolicy: RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
});

this.s3FilesFileSystem = new CfnS3FileSystem(this, 'S3FileSystem', {
  bucket: this.s3FilesBucket.bucketArn,
  roleArn: s3FilesBucketRole.roleArn,
  acceptBucketWarning: true,
});

this.s3FilesAccessPoint = new CfnS3AccessPoint(this, 'S3FilesAccessPoint', {
  fileSystemId: this.s3FilesFileSystem.attrFileSystemId,
  posixUser: { gid: '1001', uid: '1001' },
  rootDirectory: {
    path: '/wordpress',
    creationPermissions: {
      ownerGid: '1001',
      ownerUid: '1001',
      permissions: '755',
    },
  },
});
</code></pre>
<p>This is the heart of the pattern:</p>
<ul>
<li><p>The bucket is the durable storage layer.</p>
</li>
<li><p><code>CfnS3FileSystem</code> exposes that storage as an S3 Files file system.</p>
</li>
<li><p><code>CfnS3AccessPoint</code> defines how the application will enter that file tree.</p>
</li>
</ul>
<p>The access point is configured with UID/GID <code>1001</code> and a root directory of <code>/wordpress</code>, which lines up with the filesystem expectations of the Bitnami WordPress container.</p>
<h3>2. Create Mount Targets Inside the VPC</h3>
<p>The file system still needs network reachability from the ECS tasks. This creates mount targets in the <code>PRIVATE_WITH_EGRESS</code> subnets and opens NFS traffic on port <code>2049</code> from within the VPC:</p>
<pre><code class="language-ts">// From lib/s3-files-stack.ts
const mountTargetSecurityGroup = new SecurityGroup(this, 'S3FilesMountTargetSg', {
  description: 'S3 Files mount target SG',
  vpc,
});

mountTargetSecurityGroup.addIngressRule(
  Peer.ipv4(vpc.vpcCidrBlock),
  Port.tcp(2049),
  'Allow NFS from VPC',
);

const mountTargetSubnets = vpc
  .selectSubnets({ subnetType: SubnetType.PRIVATE_WITH_EGRESS, onePerAz: true })
  .subnets;

this.s3FilesMountTargets = mountTargetSubnets.map((subnet, index) =&gt; {
  return new CfnS3MountTarget(this, `S3FilesMountTarget${index + 1}`, {
    fileSystemId: this.s3FilesFileSystem.attrFileSystemId,
    subnetId: subnet.subnetId,
    securityGroups: [mountTargetSecurityGroup.securityGroupId],
  });
});
</code></pre>
<p>That is an important detail because this is not just an S3 bucket reference in ECS. The workload needs the network path to the mounted file system, so the mount target configuration matters.</p>
<h3>3. Give the Service and Tasks the Right Permissions (IAM)</h3>
<p>There are two sides to the permission model here:</p>
<p>First, <code>S3 Files</code> itself needs a specific service-linked role that lets the service synchronize data between the file system and the S3 bucket. In <code>lib/s3-files-stack.ts</code>, the role is assumed by <code>elasticfilesystem.amazonaws.com</code> and gets bucket, object, and EventBridge permissions:</p>
<pre><code class="language-typescript">// From lib/s3-files-stack.ts
const s3FilesBucketRole = new Role(this, 'S3FilesBucketRole', {
  assumedBy: new ServicePrincipal('elasticfilesystem.amazonaws.com', {
    conditions: {
      StringEquals: { 'aws:SourceAccount': this.account },
      ArnLike: { 'aws:SourceArn': `arn:aws:s3files:\({this.region}:\){this.account}:file-system/*` },
    },
  }),
});

s3FilesBucketRole.addToPolicy(new PolicyStatement({
  actions: ['s3:ListBucket', 's3:GetObject*', 's3:PutObject*', 's3:DeleteObject*'],
  resources: [bucket.bucketArn, `${bucket.bucketArn}/*`],
}));
</code></pre>
<p>Second, the ECS task role needs permission to actually use the mounted file system. In <code>lib/app-stack.ts</code>, the task gets the AWS managed policy <code>AmazonS3FilesClientReadWriteAccess</code> plus explicit S3 read/list access to the bucket:</p>
<pre><code class="language-ts">// From lib/app-stack.ts
fargateService.taskDefinition.taskRole.addManagedPolicy(
  ManagedPolicy.fromAwsManagedPolicyName('AmazonS3FilesClientReadWriteAccess'),
);

fargateService.taskDefinition.taskRole.addToPrincipalPolicy(new PolicyStatement({
  sid: 'S3ObjectReadAccess',
  actions: ['s3:GetObject', 's3:GetObjectVersion'],
  resources: [`${bucketArn}/*`],
}));

fargateService.taskDefinition.taskRole.addToPrincipalPolicy(new PolicyStatement({
  sid: 'S3BucketListAccess',
  actions: ['s3:ListBucket'],
  resources: [bucketArn],
}));
</code></pre>
<p>This split is easy to miss when you first look at the feature. You need permissions for the service-side bucket integration and for the task-side runtime access.</p>
<h3>4. Patch the ECS Task Definition with <code>s3FilesVolumeConfiguration</code></h3>
<p>The ECS service itself is created with the familiar <code>ApplicationLoadBalancedFargateService</code> construct:</p>
<pre><code class="language-ts">// From lib/app-stack.ts
const fargateService = new ApplicationLoadBalancedFargateService(this, 'AppService', {
  serviceName: 'WordpressDemoService',
  cluster,
  cpu: 256,
  memoryLimitMiB: 512,
  desiredCount: 2,
  publicLoadBalancer: true,
  taskImageOptions: {
    containerName: 'WordpressDemoContainer',
    family: 'WordpressDemoTask',
    image: ContainerImage.fromEcrRepository(ecrRepo, 'latest'),
    containerPort: 8080,
  },
  minHealthyPercent: 100,
});
</code></pre>
<p>Then the default container gets a mount point:</p>
<pre><code class="language-ts">// From lib/app-stack.ts
const volumeName = 's3files';
const mountPath = '/bitnami/wordpress';  // App's data path

fargateService.taskDefinition.defaultContainer?.addMountPoints({
  containerPath: mountPath,
  sourceVolume: volumeName,
  readOnly: false,
});
</code></pre>
<p>And finally, since high-level L2 construct support is still evolving for this feature, we use a CDK <strong>Escape Hatch</strong>. This allows us to "drop down" to the underlying CloudFormation resource (<code>CfnTaskDefinition</code>) and manually configure the <code>s3FilesVolumeConfiguration</code> property:</p>
<pre><code class="language-ts">// From lib/app-stack.ts
const cfnTaskDefinition = fargateService.taskDefinition.node.defaultChild as CfnTaskDefinition;
const existingVolumes = Array.isArray(cfnTaskDefinition.volumes)
  ? cfnTaskDefinition.volumes
  : [];

cfnTaskDefinition.volumes = [
  ...existingVolumes,
  {
    name: volumeName,
    s3FilesVolumeConfiguration: {
      accessPointArn: s3FilesAccessPoint.attrAccessPointArn,
      fileSystemArn: s3FilesFileSystem.attrFileSystemArn,
      rootDirectory: '/',
    },
  },
];
</code></pre>
<blockquote>
<p><strong>Note:</strong> Using Escape Hatches is a standard practice in CDK when you need to use a new AWS feature before the high-level constructs have been updated to support it.</p>
</blockquote>
<h2>End-to-End Request Flow</h2>
<p>Once deployed, the runtime model is straightforward:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/532a58b8-70d6-47c3-b235-4170113aefd5.png" alt="End-to-end request flow for WordPress App on AWS ECS Fargate with Amazon S3 Files." style="display:block;margin:0 auto" />

<ol>
<li><p>A request hits the Application Load Balancer.</p>
</li>
<li><p>One of the Fargate WordPress tasks handles it.</p>
</li>
<li><p>WordPress reads or writes persistent content under <code>/bitnami/wordpress</code>.</p>
</li>
<li><p>That path is backed by the S3 Files volume.</p>
</li>
<li><p>The durable backing store for that content is the S3 bucket from the <code>S3Files</code> stack.</p>
</li>
</ol>
<p>That means if a task is replaced or if the service scales horizontally, the content directory is still shared and persistent.</p>
<h2>Why the Mount Path Matters</h2>
<p>One subtle but important detail in this demo is that the S3 Files volume is mounted at <code>/bitnami/wordpress</code>, not at some arbitrary side directory.</p>
<p>That is what keeps the application model simple. The container image already expects its writable application data there, so the infrastructure is adapting to the app rather than forcing the app to adapt to the infrastructure.</p>
<p>That is a big part of why this same pattern can apply to other stateful applications. For example, in the case of Strapi, the mount path would be <code>/public/uploads</code>. If you can identify the directory that the application treats as its durable shared state, you can often mount <code>S3 Files</code> there and avoid more invasive application changes.</p>
<h2>Why this is Attractive for Stateful Applications</h2>
<p>What I like about this approach is that it keeps the application architecture simple.</p>
<p>You do not need to teach WordPress how to store media in S3. You do not need to redesign the app around object storage APIs. And you do not need to split "database state" from "filesystem state" in an application-specific way just to make containers viable.</p>
<p>Instead, you can preserve the existing runtime assumptions:</p>
<ul>
<li><p>The app writes files.</p>
</li>
<li><p>Multiple tasks can share those files.</p>
</li>
<li><p>The data survives task churn.</p>
</li>
<li><p>The durable backing store is S3.</p>
</li>
</ul>
<p>That last point is especially appealing because S3 has operational advantages people already know how to use: lifecycle policies, replication strategies, inventory, access controls, and long-term storage economics.</p>
<blockquote>
<p><strong>Practical Note:</strong> Because S3 Files uses an EFS-backed cache, writes are available to other tasks almost instantly. However, the background synchronization to the S3 bucket itself can take 30 to 60 seconds. If you're checking the S3 console for your files immediately after an upload, don't panic if they don't show up right away!</p>
</blockquote>
<h2>How Does It Compare?</h2>
<p>When deciding how to handle WordPress storage, you generally have three paths:</p>
<h3>1. Amazon S3 Files (The New Way)</h3>
<ul>
<li><p><strong>Pros:</strong> Near-infinite storage of S3, <strong>up to 90% lower cost compared to EFS</strong>, and easier data management (it's just a bucket!).</p>
</li>
<li><p><strong>Cons:</strong> Currently requires lower-level configuration (L1 constructs) in CDK.</p>
</li>
</ul>
<h3>2. Amazon EFS (The Traditional Way)</h3>
<ul>
<li><p><strong>Pros:</strong> Mature, fully POSIX-compliant, and has excellent L2 construct support in CDK.</p>
</li>
<li><p><strong>Cons:</strong> More expensive than S3 for large amounts of "cold" media files, and managing EFS lifecycle policies can be more complex than S3.</p>
</li>
</ul>
<h3>3. "Offload Media" Plugins</h3>
<ul>
<li><p><strong>Pros:</strong> No complex infrastructure needed; plugins like <em>WP Offload Media</em> sync your <code>uploads</code> folder directly to S3 and rewrite URLs.</p>
</li>
<li><p><strong>Cons:</strong> Usually only handles media. Plugins and themes still need a persistent home, and these plugins often have a premium cost or require extra configuration within WordPress itself.</p>
</li>
</ul>
<h3>The "S3 Files" Advantage</h3>
<p>S3 Files is particularly powerful because it treats your bucket as the source of truth. You can use standard S3 features like <strong>Lifecycle Policies</strong>, <strong>Replication</strong>, and <strong>Inventory</strong> while your application thinks it's just writing to a local disk.</p>
<p>This makes it an ideal fit for:</p>
<ul>
<li><p><strong>Legacy Migrations:</strong> Apps that expect a filesystem, but you want to store data in S3.</p>
</li>
<li><p><strong>Shared Assets:</strong> Multiple containers needing access to a common set of images, logs, or configurations.</p>
</li>
<li><p><strong>Cost Management:</strong> Leveraging S3's low-cost storage classes for large datasets.</p>
</li>
</ul>
<h2>The Future</h2>
<p>Using L1 constructs today feels a bit like "coding close to the metal," but it gives us access to this powerful feature right now. As the feature matures, we can expect the AWS CDK team to release <strong>L2 constructs</strong> that will make this integration much simpler.</p>
<h2>Taking it to Production</h2>
<p>While this demo gives you a solid foundation, there are a few "Day 2" enhancements you'll want to consider before going live:</p>
<ul>
<li><p><strong>CloudFront &amp; WAF:</strong> Place a CloudFront distribution in front of your ALB to cache static assets (like images from S3) at edge locations. This reduces the load on your Fargate tasks and saves you money on data transfer. Don't forget to attach <strong>AWS WAF</strong> to block SQL injection and cross-site scripting (XSS) attacks.</p>
</li>
<li><p><strong>Database High Availability:</strong> In this demo, we use a single MariaDB instance. For production, you should enable <strong>Multi-AZ</strong> for RDS to ensure your database can failover automatically if an Availability Zone goes down.</p>
</li>
<li><p><strong>Backup &amp; Recovery:</strong> While S3 is highly durable, you should still use <strong>AWS Backup</strong> or S3 Versioning to protect against accidental deletions or application-level corruption.</p>
</li>
<li><p><strong>Auto-scaling:</strong> Configure your Fargate service to scale the number of tasks automatically based on CPU or memory usage. This ensures your site stays responsive during traffic spikes without over-provisioning.</p>
</li>
<li><p><strong>Monitoring:</strong> Set up <strong>CloudWatch Alarms</strong> for your ALB's 5XX errors and RDS CPU utilization, so you're the first to know if something goes wrong.</p>
</li>
</ul>
<h2>Conclusion</h2>
<p>Amazon S3 Files represents a significant step forward in simplifying stateful container architecture. By combining the infinite scale of S3 with the accessibility of a file system, it provides a flexible, cost-effective solution for any stateful workload on AWS, including Lambda, EC2, ECS, EKS, Fargate, and Batch.</p>
<p>If you are working with a CMS, an internal platform, or any application that expects shared writable files, this is one of the most promising new AWS features to experiment with.</p>
<p>Check out the full source code for this demo project <a href="https://github.com/haZya/s3-files-demo-with-wp">here</a> and start modernizing your stateful apps today!</p>
]]></content:encoded></item><item><title><![CDATA[Deep Dive: Building a Secure, Event-Driven File Processing Pipeline with AWS CDK]]></title><description><![CDATA[When people say "file upload," they often mean a simple PUT to S3 and a database row. In a hobby project, that's fine. In production, it’s a liability.
Production-grade file processing requires answer]]></description><link>https://blog.hazya.dev/deep-dive-building-a-secure-event-driven-file-processing-pipeline-with-aws-cdk</link><guid isPermaLink="true">https://blog.hazya.dev/deep-dive-building-a-secure-event-driven-file-processing-pipeline-with-aws-cdk</guid><category><![CDATA[aws-cdk]]></category><category><![CDATA[cloud architecture]]></category><category><![CDATA[stepfunction]]></category><category><![CDATA[Microservices]]></category><category><![CDATA[event-driven-architecture]]></category><dc:creator><![CDATA[Hasitha Wickramasinghe]]></dc:creator><pubDate>Fri, 03 Apr 2026 14:10:59 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/752f7f7b-7006-446e-8457-c0e571ac9c21.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When people say "file upload," they often mean a simple <code>PUT</code> to S3 and a database row. In a hobby project, that's fine. In production, it’s a liability.</p>
<p>Production-grade file processing requires answering a much harder set of questions:</p>
<ul>
<li><p>How do I isolate untrusted files from my clean assets?</p>
</li>
<li><p>Where does malware scanning fit without blocking the user?</p>
</li>
<li><p>How do I validate file types beyond the easily spoofed browser MIME type?</p>
</li>
<li><p>How do I fan out status updates to users and other services?</p>
</li>
<li><p>How do I replace old files safely without leaking orphaned objects?</p>
</li>
<li><p>How do I keep the whole system event-driven instead of building a tightly coupled "upload monolith"?</p>
</li>
</ul>
<p>In this article, we’ll walk through a self-contained file-processing microservice built with <strong>AWS CDK and TypeScript</strong>. We leverage S3, EventBridge, GuardDuty Malware Protection, Step Functions, and API Gateway WebSockets to build a pipeline that handles everything from ingestion to real-time status delivery.</p>
<blockquote>
<p><strong>Complete Source Code:</strong> You can find the full implementation, including all Lambda handlers and CDK stacks, in the <a href="https://github.com/haZya/upload-file-processing-pipeline">GitHub Repository</a>.</p>
</blockquote>
<h2>What We Are Building</h2>
<p>At a high level, the service follows a strict security-first workflow:</p>
<ol>
<li><p><strong>Ingestion:</strong> A client requests a presigned POST and uploads a file to a <strong>Staging Bucket</strong>.</p>
</li>
<li><p><strong>Registration:</strong> An S3 event triggers a Lambda to record the upload as <code>PENDING_SCAN</code> in DynamoDB.</p>
</li>
<li><p><strong>Security Gate:</strong> <strong>AWS GuardDuty Malware Protection</strong> scans the object asynchronously.</p>
</li>
<li><p><strong>Orchestration:</strong> A GuardDuty scan result event triggers an <strong>AWS Step Functions Express Workflow</strong>.</p>
</li>
<li><p><strong>Processing:</strong> The workflow validates the file signature, moves it to the <strong>Clean Bucket</strong>, transforms images (using <code>sharp</code>), updates metadata, and cleans up old files.</p>
</li>
<li><p><strong>Real-time Notify:</strong> Status changes are fanned out via <strong>EventBridge</strong> to a <strong>WebSocket API</strong> for immediate client feedback.</p>
</li>
</ol>
<p>This design ensures a clean separation between ingestion, security, orchestration, and real-time communication.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/a3dfdac1-f4ec-42d2-8e5b-6467a335fd96.png" alt="Step Functions Graph." style="display:block;margin:0 auto" />

<h2>Architecture Overview</h2>
<p>The repository is organized into five modular CDK stacks, keeping domain concerns isolated:</p>
<ul>
<li><p><code>lib/storage-stack.ts</code> - Private S3 buckets + CloudFront distribution for serving assets.</p>
</li>
<li><p><code>lib/database-stack.ts</code> - DynamoDB tables for uploads and relation tracking.</p>
</li>
<li><p><code>lib/guard-duty-stack.ts</code> - GuardDuty Malware Protection for the staging bucket.</p>
</li>
<li><p><code>lib/upload-processing-stack.ts</code> - The orchestration core: EventBridge, Step Functions, and Lambdas.</p>
</li>
<li><p><code>lib/websocket-stack.ts</code> - API Gateway WebSocket API and connection tracking.</p>
</li>
</ul>
<p>The app wiring in <code>bin/app.ts</code> is intentionally simple, demonstrating the power of stack composition:</p>
<pre><code class="language-ts">const storageStack = new StorageStack(app, "Storage");
const databaseStack = new DatabaseStack(app, "Database");
const guardDutyStack = new GuardDutyStack(app, "GuardDuty", {
  stagingUploadBucket: storageStack.stagingUploadBucket,
});
const webSocketStack = new WebSocketStack(app, "WebSocket", {});

const uploadProcessingStack = new UploadProcessingStack(app, "UploadProcessing", {
  stagingUploadBucket: storageStack.stagingUploadBucket,
  uploadBucket: storageStack.uploadBucket,
  uploadsTable: databaseStack.uploadsTable,
  uploadRelationsTable: databaseStack.uploadRelationsTable,
  webSocket: webSocketStack,
});
</code></pre>
<h2>Why the Dual-bucket Strategy Matters</h2>
<p>The first important design choice is the storage layer. Instead of uploading directly into your final bucket, this service uses two buckets: a <strong>staging bucket</strong> for untrusted files and an <strong>upload bucket</strong> for processed, clean assets. This "DMZ" approach prevents your final storage from ever becoming a dumping ground for unscanned content.</p>
<p>Here is the essence of <code>lib/storage-stack.ts</code>:</p>
<pre><code class="language-ts">this.stagingUploadBucket = new Bucket(this, "StagingUploadBucket", {
  blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
  enforceSSL: true,
  minimumTLSVersion: 1.2,
  eventBridgeEnabled: true,
  removalPolicy: RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
  lifecycleRules: [
    {
      abortIncompleteMultipartUploadAfter: Duration.days(1),
      expiration: Duration.days(7),
    },
  ],
});

this.uploadBucket = new Bucket(this, "UploadBucket", {
  blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
  enforceSSL: true,
  minimumTLSVersion: 1.2,
});

const cachePolicy = new CachePolicy(this, "UploadBucketCachePolicy", {
    defaultTtl: Duration.days(7),
    minTtl: Duration.seconds(0),
    maxTtl: Duration.days(30),
});

new Distribution(this, "UploadBucketDistribution", {
  defaultBehavior: {
    origin: S3BucketOrigin.withOriginAccessControl(this.uploadBucket),
    viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
    cachePolicy,
  },
  minimumProtocolVersion: SecurityPolicyProtocol.TLS_V1_3_2025,
});
</code></pre>
<p>There are a few good ideas packed into this:</p>
<ul>
<li><p>The staging bucket is private and event-enabled.</p>
</li>
<li><p>Incomplete multipart uploads are cleaned up automatically.</p>
</li>
<li><p>The clean upload bucket is also private.</p>
</li>
<li><p>CloudFront uses Origin Access Control, so S3 stays off the public internet path.</p>
</li>
</ul>
<blockquote>
<p><strong>Pro Tip:</strong> While this setup enforces TLS and private access, you should consider adding <code>BucketEncryption.KMS</code> for defense-in-depth and granular auditing in production environments.</p>
</blockquote>
<p>A production-oriented version with a KMS key could look more like this:</p>
<pre><code class="language-ts">const key = new kms.Key(this, "UploadsKey", {
  enableKeyRotation: true,
});

const uploadBucket = new Bucket(this, "UploadBucket", {
  encryption: BucketEncryption.KMS,
  encryptionKey: key,
  bucketKeyEnabled: true,
  // ...Other options
});
</code></pre>
<h2>Modeling Uploads in DynamoDB</h2>
<p>The database layer splits responsibilities across two tables to handle different access patterns:</p>
<ul>
<li><p><code>UploadsTable</code> stores every upload event (the historical record).</p>
</li>
<li><p><code>UploadRelationsTable</code> tracks the current file for a specific business entity (e.g., "user:123:avatar").</p>
</li>
</ul>
<p><code>lib/database-stack.ts</code> defines two useful GSIs on <code>UploadsTable</code>:</p>
<ul>
<li><p><code>ByRelation</code> for querying uploads by logical owner or relation.</p>
</li>
<li><p><code>ByStagingKey</code> for resolving the latest record tied to a staging object key.</p>
</li>
</ul>
<pre><code class="language-ts">this.uploadsTable = new TableV2(this, "UploadsTable", {
  partitionKey: { name: "uploadId", type: AttributeType.STRING },
  pointInTimeRecoverySpecification: {
    pointInTimeRecoveryEnabled: true,
  },
  globalSecondaryIndexes: [
    {
      indexName: "ByRelation",
      partitionKey: { name: "relationKey", type: AttributeType.STRING },
      sortKey: { name: "createdAt", type: AttributeType.STRING },
    },
    {
      indexName: "ByStagingKey",
      partitionKey: { name: "stagingKey", type: AttributeType.STRING },
      sortKey: { name: "createdAt", type: AttributeType.STRING },
    },
  ],
});

this.uploadRelationsTable = new TableV2(this, "UploadRelationsTable", {
    partitionKey: { name: "relationKey", type: AttributeType.STRING },
    removalPolicy: RemovalPolicy.DESTROY,
    pointInTimeRecoverySpecification: {
        pointInTimeRecoveryEnabled: true,
    },
});
</code></pre>
<p>This separation allows you to query "what happened to upload X" while simultaneously allowing the UI to instantly find the "current hero image" for a product.</p>
<p>The <code>lambda/upload/update-status.ts</code> handler uses that second table to atomically move a relation to the newest successful upload while keeping a reference to the previous one for cleanup.</p>
<h2>Ingestion: Generating the Presigned Upload</h2>
<p>The upload path starts in <code>lambda/upload/generate-presigned-post.ts</code>. A key detail here is binding metadata directly into the presigned POST conditions. By forcing <code>x-amz-meta-author-id</code> and <code>x-amz-meta-relation-key</code> into the upload itself, we ensure downstream processors never have to "guess" the context.</p>
<pre><code class="language-ts">const { url, fields } = await createPresignedPost(s3, {
  Bucket: stagingBucket,
  Key: key,
  Conditions: [
    ["content-length-range", 0, 100 * 1024 * 1024],
    ["starts-with", "$Content-Type", contentType ?? ""],
    ["eq", "$x-amz-meta-relation-key", relationKey],
    ["eq", "$x-amz-meta-author-id", userId],
  ],
  Fields: {
    ...(contentType ? { "Content-Type": contentType } : {}),
    "x-amz-meta-relation-key": relationKey,
    "x-amz-meta-author-id": userId,
  },
  Expires: 600,
});
</code></pre>
<h2>Security First: GuardDuty Malware Protection for S3</h2>
<p>We use <strong>GuardDuty Malware Protection for S3</strong> as our security gate. It scans objects asynchronously and emits EventBridge events, which is far more efficient and scalable than writing custom antivirus logic in Lambda.</p>
<p>From <code>lib/guard-duty-stack.ts</code>:</p>
<pre><code class="language-ts">const plan = new CfnMalwareProtectionPlan(this, "S3MalwareProtectionPlan", {
  role: role.roleArn,
  protectedResource: {
    s3Bucket: {
      bucketName: stagingUploadBucket.bucketName,
    },
  },
  actions: {
    tagging: { status: "ENABLED" },
  },
});
</code></pre>
<p>The stack also grants the GuardDuty service role the minimum S3, EventBridge, and optional KMS permissions it needs.</p>
<p>Why this design works well:</p>
<ul>
<li><p>S3 remains the system of record for raw files.</p>
</li>
<li><p>GuardDuty performs the malware check asynchronously.</p>
</li>
<li><p>The result shows up as an EventBridge event.</p>
</li>
<li><p>Object tagging can record scan outcomes such as <code>NO_THREATS_FOUND</code> and <code>THREATS_FOUND</code>.</p>
</li>
</ul>
<h2>EventBridge as the Backbone</h2>
<p>The service uses EventBridge in two different ways.</p>
<p>First, AWS service events kick off work:</p>
<ul>
<li><p>S3 <code>Object Created</code> events register the upload as pending.</p>
</li>
<li><p>GuardDuty malware scan result events start the Step Functions workflow.</p>
</li>
</ul>
<p>Second, the service emits its own internal domain events on a dedicated bus: <code>UploadStatusChanged</code>.</p>
<p>That gives the system a very clean shape: inbound events drive orchestration, and outbound events describe state changes for consumers.</p>
<pre><code class="language-ts">new Rule(this, "S3ObjectCreatedRule", {
  eventPattern: {
    source: ["aws.s3"],
    detailType: ["Object Created"],
    detail: {
      bucket: { name: [stagingUploadBucket.bucketName] },
    },
  },
  targets: [
    new LambdaFunction(registerUploadLambda, {
      event: RuleTargetInput.fromObject({
        bucket: EventField.fromPath("$.detail.bucket.name"),
        key: EventField.fromPath("$.detail.object.key"),
      }),
      retryAttempts: 4,
      deadLetterQueue: s3ObjectCreatedDeliveryDlq,
    }),
  ],
});
</code></pre>
<p>The <code>lambda/upload/register-upload.ts</code> handler calls <code>HeadObject</code>, reads metadata, and creates a DynamoDB row with a <code>PENDING_SCAN</code> status.</p>
<h2>Orchestration: Step Functions Express Workflow</h2>
<p>This is the "brain" of the system. The actual architectural center of the repo, which manages the complex branching logic of the pipeline:</p>
<p><strong>Security Check:</strong> If GuardDuty finds a threat, delete the file and fail. <strong>Deep Validation:</strong> Use file-type to inspect the "magic numbers" of the file, ignoring spoofed MIME headers. <strong>Parallel Processing:</strong> Transform images (using <code>sharp</code>) and delete the staging file simultaneously.</p>
<p>The workflow is triggered by GuardDuty scan results. If the object is clean, the pipeline validates the file, resolves the final key, copies the object to the clean bucket, optionally generates image variants, stores metadata, updates the upload record, emits a status event, and cleans up a previously replaced upload if needed.</p>
<p>If the object is malicious or invalid, it deletes the staging object and marks the upload as failed.</p>
<h3>Why Express Workflow?</h3>
<p>We use Express Workflows with <code>StateMachineType.EXPRESS</code> for this orchestration because file processing is a high-volume, short-lived task where latency and cost-efficiency are paramount. Having said that, express workflows do not support <a href="https://docs.aws.amazon.com/step-functions/latest/dg/connect-to-resource.html">long-running callback patterns</a> like <code>.waitForTaskToken</code> or <code>.sync</code>. If you need to wait for external human approval (HITL) or a long asynchronous job, switch to Standard.</p>
<h3>The Key Tasks in the Workflow</h3>
<p><code>lib/upload-processing-stack.ts</code> wires several Lambdas into the state machine:</p>
<ul>
<li><p><code>validate.ts</code> - checks file signature using <code>file-type</code>.</p>
</li>
<li><p><code>resolve-final-key.ts</code> - generates the durable object key.</p>
</li>
<li><p><code>transform-image.ts</code> - creates WebP derivatives with <code>sharp</code>.</p>
</li>
<li><p><code>add-metadata.ts</code> - persists final key, dimensions, and formats.</p>
</li>
<li><p><code>update-status.ts</code> - marks the upload successful or failed and updates the relation state.</p>
</li>
<li><p><code>cleanup-replaced-upload.ts</code> - deletes the previous file version if the relation now points elsewhere.</p>
</li>
</ul>
<p>It also uses direct AWS SDK integrations for S3 copy and delete operations, which keeps the workflow explicit and avoids extra Lambda glue.</p>
<h3>The Workflow Definition</h3>
<p>This is the part many teams hand-wave. This demo does not. The state machine is modeled directly in CDK:</p>
<pre><code class="language-ts">const coreWorkflow = new Choice(this, "ScanResultOK")
  .when(
    Condition.stringEquals("$.scanResultStatus", "NO_THREATS_FOUND"),
    validateFileTask.next(
      new Choice(this, "IsFileValid")
        .when(
          Condition.booleanEquals("$.isValid", true),
          resolveFinalKeyTask
            .next(copyToUploadBucketTask)
            .next(transformAndDelete),
        )
        .otherwise(deleteInvalidStagingObjectTask.next(markValidationFailedTask)),
    ),
  )
  .otherwise(deleteThreatStagingObjectTask.next(markThreatDetectedTask));

const definition = new Parallel(this, "MainWorkflowGroup", {
  outputPath: "$.[0]",
})
  .branch(coreWorkflow)
  .addCatch(
    updateUploadStatusFailureTask
      .next(uploadStatusEmitFailureOnCatch)
      .next(workflowFailState),
    {
      errors: [Errors.ALL],
      resultPath: "$.error",
    },
  )
  .next(postProcessingFlow);

const stateMachine = new StateMachine(this, "FileUploadStateMachine", {
  definitionBody: DefinitionBody.fromChainable(definition),
  timeout: Duration.minutes(5),
  stateMachineType: StateMachineType.EXPRESS,
  logs: {
    destination: logGroup,
    level: LogLevel.ALL,
    includeExecutionData: true,
  },
});
</code></pre>
<p>There are a few design choices here worth highlighting.</p>
<p>First, the workflow branches early on the security outcome. That means a malware-positive file never reaches the rest of the processing path.</p>
<p>Second, image transformation and staging-object deletion run inside a <code>Parallel</code> state:</p>
<pre><code class="language-ts">const transformAndDelete = new Parallel(this, "TransformAndDeleteStagingFile", {
  outputPath: "$.[0]",
});

transformAndDelete.branch(
  new Choice(this, "IsImage")
    .when(Condition.stringMatches("$.mime", "image/*"), transformImageTask)
    .otherwise(new Pass(this, "SkipTransformForNonImage"))
    .afterwards()
    .next(addMetadataTask),
);

transformAndDelete.branch(deleteStagingObjectOnCopySuccessTask);
</code></pre>
<p>That is a nice example of Step Functions as a real orchestration engine, not just a fancy switch statement.</p>
<p>Third, the stack centralizes retry policies for Lambda, S3, and EventBridge tasks:</p>
<pre><code class="language-ts">private addServiceRetry(task: CallAwsService | LambdaInvoke | EventBridgePutEvents, errors: string[]) {
  task.addRetry({
    errors,
    interval: Duration.seconds(2),
    backoffRate: 2,
    maxAttempts: 3,
  });
}
</code></pre>
<p>That small helper keeps resilience consistent across the workflow.</p>
<h2>File Validation Beyond the Content-Type Header</h2>
<p>One of the easiest upload mistakes is trusting the browser-provided MIME type. This demo does better. The <code>lambda/upload/validate.ts</code> reads the first few kilobytes of the object and uses <code>file-type</code> to inspect the file signature:</p>
<pre><code class="language-ts">const object = await s3.send(new GetObjectCommand({
  Bucket: bucket,
  Key: key,
  Range: "bytes=0-4095",
}));

const buffer = await object.Body.transformToByteArray();
const detected = await fileTypeFromBuffer(buffer);
const detectedMime = detected?.mime ?? null;
const mime = detectedMime ?? head.ContentType ?? "application/octet-stream";
const isValid = !(detectedMime &amp;&amp; head.ContentType &amp;&amp; detectedMime !== head.ContentType);
</code></pre>
<p>That is still lightweight, but it already closes a very common trust gap. If the content is invalid, the workflow deletes the staging object and marks the upload as <code>VALIDATION_FAILED</code>.</p>
<h2>Image Transformation and Derivative Generation</h2>
<p>If the file is an image, the workflow creates optimized WebP variants with <code>sharp</code>.</p>
<p>From <code>lambda/upload/transform-image.ts</code>:</p>
<pre><code class="language-ts">async function createVariant(width: number, prefix: string) {
  const transformed = await sharp(buffer)
    .resize({ width, withoutEnlargement: true })
    .webp({ quality: 75 })
    .toBuffer({ resolveWithObject: true });

  const parts = event.finalKey.split("/");
  const fileName = parts.pop()!;
  const newKey = [...parts, `\({prefix}-\){fileName}`].join("/");

  await s3.send(new PutObjectCommand({
    Bucket: uploadBucket,
    Key: newKey,
    Body: transformed.data,
    ContentType: "image/webp",
  }));

  return {
    key: newKey,
    width: transformed.info.width,
    height: transformed.info.height,
    size: transformed.info.size,
    mime: "image/webp",
  };
}
</code></pre>
<p>The handler returns the original dimensions plus a <code>formats</code> array, and <code>add-metadata.ts</code> persists that back into DynamoDB. That means the final upload record is not just "file uploaded"; it is an asset descriptor that a frontend can actually use.</p>
<h3>The <code>sharp</code> Challenge</h3>
<p>Bundling native modules like <code>sharp</code> for Lambda can be tricky. We use <code>NodejsFunction</code> with Docker-based bundling to ensure the binary is compiled for the Lambda Linux environment:</p>
<pre><code class="language-ts">bundling: {
  nodeModules: ["sharp"],
  forceDockerBundling: true,
  environment: {
    NPM_CONFIG_BIN_LINKS: "false",
  },
}
</code></pre>
<blockquote>
<p><strong>Note:</strong> If you're on Windows, you may have to set <code>NPM_CONFIG_BIN_LINKS</code> environment variable to <code>false</code> to disable symlink creation.</p>
</blockquote>
<h2>Relation-aware Status Tracking and Atomic Replacement</h2>
<p>One of the more sophisticated patterns in this architecture is the decoupling of <strong>Uploads</strong> from <strong>Relations</strong>.</p>
<ul>
<li><p><strong>An Upload</strong> is an immutable historical record. It tracks the journey of a specific file from staging to final storage, including its metadata and processing results.</p>
</li>
<li><p><strong>A Relation</strong> is a logical pointer within your application domain (e.g., <code>user:42:avatar</code> or <code>product:99:hero-image</code>) that resolves to a specific, successful upload.</p>
</li>
</ul>
<h3>Why This Decoupling Matters</h3>
<p>In many systems, updating a file means overwriting the existing object or manually deleting the old one before uploading the new one. This often leads to race conditions, orphaned files, or broken UI states.</p>
<p>By using a dedicated <code>UploadRelationsTable</code>, we implement a <strong>fail-safe replacement strategy</strong>:</p>
<ol>
<li><p><strong>Late Binding:</strong> The application relation only updates <em>after</em> the entire processing pipeline succeeds.</p>
</li>
<li><p><strong>Reference Tracking:</strong> When <code>lambda/upload/update-status.ts</code> marks a new upload as complete, it updates the relation to point to the new asset while capturing the <code>previousUploadId</code>.</p>
</li>
<li><p><strong>Automatic Cleanup:</strong> This hand-off allows the <code>lambda/upload/cleanup-replaced-upload.ts</code> task to safely delete the old file and all its generated variants (WebP, thumbnails, etc.) without impacting the live application.</p>
</li>
</ol>
<p>This approach ensures that your application always points to a valid, scanned asset, and your storage never becomes a graveyard of abandoned "Version 1" files. It provides the benefits of object versioning without the complexity of S3 Bucket Versioning for your application logic.</p>
<h2>Real-time Communication with WebSockets</h2>
<p>The last mile is status delivery. Because the pipeline is asynchronous, the user needs immediate feedback. The service emits <code>UploadStatusChanged</code> events to an internal EventBridge bus. The <code>lambda/upload/emit-upload-status.ts</code> subscriber Lambda then looks up active connections in DynamoDB and pushes the update via <strong>API Gateway WebSockets</strong>.</p>
<pre><code class="language-ts">private createUploadStatusEmitTask(id: string, uploadStatusBus: EventBus) {
  return new EventBridgePutEvents(this, id, {
    entries: [
      {
        eventBus: uploadStatusBus,
        detailType: "UploadStatusChanged",
        detail: TaskInput.fromJsonPathAt("$"),
        source: "com.file-processing.upload",
      },
    ],
    resultPath: "$.eventBridgeResult",
  });
}
</code></pre>
<p>The WebSocket stack stores connections in DynamoDB with a TTL so stale sessions age out automatically.</p>
<p>From <code>lib/websocket-stack.ts</code>:</p>
<pre><code class="language-ts">const connectionsTable = new TableV2(this, "WebSocketConnectionsTable", {
  partitionKey: { name: "PK", type: AttributeType.STRING },
  sortKey: { name: "SK", type: AttributeType.STRING },
  timeToLiveAttribute: "ttl",
  pointInTimeRecoverySpecification: {
    pointInTimeRecoveryEnabled: true,
  },
  dynamoStream: StreamViewType.NEW_AND_OLD_IMAGES,
});
</code></pre>
<p>And the connection handler refreshes TTL on inbound messages to keep live sockets active.</p>
<blockquote>
<p><strong>Note:</strong> The <code>lambda/websocket/authorizer.ts</code> in the demo is explicitly mocked to return a hard-coded demo subject after "verification". For a real deployment, replace that with actual JWT verification against Cognito or your identity provider.</p>
</blockquote>
<h2>End-to-End Flow</h2>
<p>Here is the mental model I would use when explaining the pipeline:</p>
<pre><code class="language-text">Client
  -&gt; Request presigned POST
  -&gt; Upload file to staging bucket

S3 Object Created
  -&gt; EventBridge rule
  -&gt; Register-upload Lambda
  -&gt; DynamoDB status = PENDING_SCAN

GuardDuty scans staging object
  -&gt; EventBridge scan result
  -&gt; Step Functions Express workflow

If clean
  -&gt; Validate file signature
  -&gt; Resolve final key
  -&gt; Copy to clean bucket
  -&gt; Optionally transform image
  -&gt; Save metadata
  -&gt; Mark upload complete
  -&gt; Clean previous version
  -&gt; Emit UploadStatusChanged

If invalid or malicious
  -&gt; Delete staging object
  -&gt; Mark failed status
  -&gt; Emit UploadStatusChanged

Internal event bus
  -&gt; WebSocket notifier Lambda
  -&gt; Connected clients receive status update
</code></pre>
<h3>Step Functions Graph for a Successful File Upload.</h3>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/9fd624b7-8f1a-4058-81f0-9a3703420688.png" alt="Step Functions success graph." style="display:block;margin:0 auto" />

<h3>WebSocket Notification for a Successful File Upload</h3>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/777da2fb-6f3e-4545-8a1f-166b510b7203.png" alt="WebSocket notification for success." style="display:block;margin:0 auto" />

<h2>Architectural Benefits</h2>
<p>The strength of this architecture lies in its strict adherence to the <strong>Single Responsibility Principle (SRP)</strong> at the infrastructure level. Rather than building a monolithic "Upload Lambda," we've distributed concerns across managed services that are natively optimized for their respective tasks.</p>
<ul>
<li><p><strong>Decoupled Storage (S3):</strong> S3 is used strictly for what it does best—durable, scalable object storage—rather than being cluttered with orchestration logic.</p>
</li>
<li><p><strong>Security as a Service (GuardDuty):</strong> By offloading malware scanning to GuardDuty, we eliminate the operational burden and scaling limits of maintaining custom antivirus signatures in Lambda.</p>
</li>
<li><p><strong>Explicit Orchestration (Step Functions):</strong> Complex branching, retries, and parallel execution are managed in a visual state machine, making the workflow observable and easy to modify without touching code.</p>
</li>
<li><p><strong>State vs. Event (DynamoDB &amp; EventBridge):</strong> DynamoDB maintains the source of truth for asset metadata, while EventBridge acts as the nervous system, routing state changes to downstream consumers without tight coupling.</p>
</li>
<li><p><strong>Asynchronous Communication (WebSockets):</strong> Real-time feedback is treated as a reactive side-effect of domain events, not a synchronous dependency of the processing pipeline.</p>
</li>
</ul>
<p>This modularity ensures that the service is not just easy to build, but easy to evolve. You can swap the image processor, add new security gates, or change how notifications are delivered without ever disrupting the core ingestion path.</p>
<h2>Scaling to Production: Roadmap and Optimizations</h2>
<p>While this architecture provides a robust foundation, transitioning to a high-scale production environment often requires additional optimizations for efficiency, cost, and observability.</p>
<h3>1. Extending the Processing Pipeline</h3>
<p>The Step Functions backbone makes it trivial to insert new processing stages. As your application grows, you can easily integrate:</p>
<ul>
<li><p><strong>AI/ML Insights:</strong> Add Amazon Rekognition for moderation or Textract for document analysis.</p>
</li>
<li><p><strong>Frontend Optimization:</strong> Generate <strong>Blurhash</strong> values for instant placeholders or low-resolution image previews.</p>
</li>
<li><p><strong>Rich Metadata:</strong> Extract EXIF data, strip sensitive location tags, or generate multi-format derivatives (AVIF, WebP, PDF).</p>
</li>
<li><p><strong>Compliance:</strong> Implement automated PII (Personally Identifiable Information) detection before files reach the clean bucket.</p>
</li>
</ul>
<h3>2. Implementing Content De-duplication</h3>
<p>To optimize storage costs and minimize redundant processing (like malware scanning or image transformation), implement <strong>content-addressable storage</strong>:</p>
<ol>
<li><p><strong>Client-side Hashing:</strong> Compute a SHA-256 digest on the client before upload.</p>
</li>
<li><p><strong>Dedupe Check:</strong> Query DynamoDB to see if the digest already exists in the <code>Clean Bucket</code>.</p>
</li>
<li><p><strong>Logical Mapping:</strong> If a match is found, create a new <code>Relation</code> record pointing to the existing <code>finalKey</code> instead of initiating a new upload.</p>
</li>
</ol>
<h3>3. Proactive Observability and Alarming</h3>
<p>Relying on DLQs is the first step, but production systems require active monitoring. Implement CloudWatch Alarms for:</p>
<ul>
<li><p><strong>Pipeline Failures:</strong> Monitor Step Functions <code>ExecutionsFailed</code> and <code>ExecutionsTimedOut</code>.</p>
</li>
<li><p><strong>Infrastructure Health:</strong> Track Lambda <code>Errors</code> and <code>Throttles</code>, specifically for the image processing and metadata tasks.</p>
</li>
<li><p><strong>Queue Backlog:</strong> Alarm when DLQ message counts exceed zero to ensure human intervention for unhandled edge cases.</p>
</li>
</ul>
<h3>4. Explicit Business-level Retries</h3>
<p>Beyond infrastructure retries, your workflow should handle transient business failures (e.g., a third-party moderation API being temporarily unavailable). Use Step Functions' <code>Retry</code> logic with custom error codes like <code>TransientServiceError</code> to implement sophisticated backoff strategies without cluttering your Lambda code.</p>
<h3>5. Decoupling Notification Infrastructure</h3>
<p>In a multi-service architecture, WebSocket management should often be extracted into a dedicated <strong>Notification Microservice</strong>. The file-processing service would remain a pure producer, publishing <code>UploadStatusChanged</code> events, while the notification service handles delivery across multiple channels (WebSockets, Push Notifications, Email).</p>
<h3>6. Cross-Account Event Distribution</h3>
<p>As your organization grows, consumers of your file-processing events may live in different AWS accounts. Leverage <strong>EventBridge Bus-to-Bus routing</strong> to forward domain events to a central integration bus or directly to consumer accounts, maintaining a clean event-driven contract across team boundaries.</p>
<h2>Production Check-list</h2>
<ul>
<li><p>Replace mock authorizers with robust JWT/OIDC verification.</p>
</li>
<li><p>Implement customer-managed KMS keys for encryption at rest (S3 &amp; DynamoDB).</p>
</li>
<li><p>Protected CloudFront distribution by associating it with a web ACL that includes AWS WAF managed rules and IP-based rate limiting.</p>
</li>
<li><p>Define granular IAM boundaries and S3 Bucket Policies (Least Privilege).</p>
</li>
<li><p>Add structured logging and X-Ray tracing for end-to-end correlation.</p>
</li>
<li><p>Enforce S3 Object Lock or Versioning for compliance-heavy domains.</p>
</li>
</ul>
<h2>Final Thoughts</h2>
<p>In modern web applications, handling file uploads is a multi-stage challenge. You need to ensure security, perform transformations, and keep the user informed, all while maintaining a serverless, cost-effective footprint.</p>
<p>The beauty of this architecture lies in <strong>AWS Service Alignment</strong>. We aren't fighting the platform; we're using each service for what it's naturally good at:</p>
<ul>
<li><p><strong>S3</strong> for durable storage.</p>
</li>
<li><p><strong>GuardDuty</strong> for specialized security.</p>
</li>
<li><p><strong>Step Functions</strong> for stateful orchestration.</p>
</li>
<li><p><strong>EventBridge</strong> for decoupled, event-driven communication.</p>
</li>
</ul>
<p>By separating ingestion from processing and using security as a gatekeeper, you create a system that is secure by default, observable, and ready to scale.</p>
<p>👉 <a href="https://github.com/haZya/upload-file-processing-pipeline"><strong>View the full project on GitHub</strong></a></p>
]]></content:encoded></item><item><title><![CDATA[Mastering Cross-Account SNS to SQS Subscriptions with AWS CDK]]></title><description><![CDATA[In a modern microservices architecture, decoupled communication is king. AWS SNS (Simple Notification Service) and SQS (Simple Queue Service) are the bedrock of this decoupling. But as your organizati]]></description><link>https://blog.hazya.dev/mastering-cross-account-sns-to-sqs-subscriptions-with-aws-cdk</link><guid isPermaLink="true">https://blog.hazya.dev/mastering-cross-account-sns-to-sqs-subscriptions-with-aws-cdk</guid><category><![CDATA[AWS]]></category><category><![CDATA[aws-cdk]]></category><category><![CDATA[cloud architecture]]></category><category><![CDATA[Microservices]]></category><dc:creator><![CDATA[Hasitha Wickramasinghe]]></dc:creator><pubDate>Sun, 22 Mar 2026 00:33:15 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/2481c863-7673-434a-8b9c-47e9b13736ef.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In a modern microservices architecture, decoupled communication is king. AWS SNS (Simple Notification Service) and SQS (Simple Queue Service) are the bedrock of this decoupling. But as your organization grows and you move toward a multi-account strategy, you'll inevitably hit a common hurdle: <strong>How do you subscribe an SQS queue in Account B to an SNS topic in Account A?</strong></p>
<p>Cross-account subscriptions can be tricky because of permission boundaries and the "Pending Confirmation" dance. In this post, we’ll explore two ways to handle this using AWS CDK, and why one is clearly superior.</p>
<h2>The Scenario</h2>
<p>Imagine a <strong>Core Service</strong> in Account A that publishes "Task Status" events to an SNS Topic. A <strong>Consumer Service</strong> in Account B needs to process these events via an SQS Queue.</p>
<blockquote>
<p><strong>Important:</strong> For native SNS-to-SQS subscriptions, make sure the Topic and Queue are in the <strong>same AWS Region</strong>.</p>
</blockquote>
<hr />
<h2>Method 1: Subscribe as the Queue Owner (Recommended)</h2>
<p>In this approach, the owner of the SNS Topic (Account A) "opens the door" by granting permission, and the owner of the SQS Queue (Account B) "walks in" by creating the subscription.</p>
<h3>Granting Consumer Account the Permission to Subscribe</h3>
<p>Instead of managing every single subscription, the topic owner grants <code>sns:Subscribe</code> permissions to the consumer account.</p>
<p><strong>CDK Implementation (Account A):</strong></p>
<pre><code class="language-typescript">const topic = new sns.Topic(this, "StatusTopic", {
  fifo: true,
  contentBasedDeduplication: true,
});

// Grant a specific account permission to subscribe
topic.grantSubscribe(new iam.AccountPrincipal("123456789012"));

// OR: If you are in an AWS Organization, grant access to the entire Org
topic.addToResourcePolicy(new iam.PolicyStatement({
  actions: ["sns:Subscribe"],
  resources: [topic.topicArn],
  principals: [new iam.AnyPrincipal()],
  conditions: {
    StringEquals: { "aws:PrincipalOrgID": "o-xxxxxxxxxx" }
  }
}));
</code></pre>
<h3>Subscribing SQS Queue to the Topic</h3>
<p>Now, the consumer can subscribe their queue to the topic using the Topic ARN. Since they have permission on the topic and own the queue, the subscription is confirmed <strong>instantly</strong>.</p>
<p><strong>CDK Implementation (Account B):</strong></p>
<pre><code class="language-typescript">const queue = new sqs.Queue(this, "ConsumerQueue", { fifo: true });
const topicArn = "arn:aws:sns:us-east-1:111122223333:StatusTopic.fifo";

const topic = sns.Topic.fromTopicArn(this, "ImportedTopic", topicArn);

topic.addSubscription(new subs.SqsSubscription(queue, {
  rawMessageDelivery: true,
  filterPolicy: {
    status: sns.SubscriptionFilter.stringFilter({ allowlist: ["CREATED"] }),
  },
}));
</code></pre>
<blockquote>
<p><strong>Deployment tip:</strong> Deploy Account A (Topic policy/grants) before Account B creates the subscription.</p>
</blockquote>
<h3>The "Self-Service" Advantage</h3>
<p>This is the <strong>Gold Standard</strong> for microservices. Account A provides the "Event Bus" (the Topic), and consumers can:</p>
<ul>
<li><p>Create as many queues as they need.</p>
</li>
<li><p>Define their own Filter Policies without bothering the producer.</p>
</li>
<li><p>Manage their own DLQs and retry logic.</p>
</li>
<li><p>Scale independently without any manual "confirmation" steps.</p>
</li>
</ul>
<hr />
<h2>Method 2: Subscribe as the Topic Owner</h2>
<p>Here, the producer in Account A explicitly creates the subscription to the remote queue in Account B.</p>
<h3>Creating a Subscription for the Remote SQS Queue</h3>
<p>Instead of allowing the consumers to initiate the subscriptions, the topic owner creates and manages the subscriptions for the consumers.</p>
<p><strong>CDK Implementation (Account A):</strong></p>
<pre><code class="language-typescript">const queue = sqs.Queue.fromQueueArn(this, "RemoteQueue", "arn:aws:sqs:us-east-1:444455556666:ConsumerQueue.fifo");

topic.addSubscription(new subs.SqsSubscription(queue));
</code></pre>
<h3>The "Symmetric Permission" Requirement</h3>
<p>For this to work, you must also update the <strong>SQS Queue Policy</strong> in Account B to allow the SNS Topic to send messages.</p>
<p><strong>CDK Implementation (Account B):</strong></p>
<pre><code class="language-typescript">// In Account B's CDK code:
queue.addToResourcePolicy(new iam.PolicyStatement({
  actions: ["sqs:SendMessage"],
  resources: [queue.queueArn],
  principals: [new iam.ServicePrincipal("sns.amazonaws.com")],
  conditions: {
    ArnEquals: { "aws:SourceArn": "arn:aws:sns:us-east-1:111122223333:StatusTopic.fifo" },
    StringEquals: { "aws:SourceAccount": "111122223333" }
  }
}));
</code></pre>
<p><strong>The Catch:</strong> When the producer creates the subscription cross-account, it enters a <code>Pending Confirmation</code> state. AWS sends a <code>SubscriptionConfirmation</code> message to the SQS queue. The consumer then needs a confirmation step (manual or automated, e.g., a Lambda that calls <code>ConfirmSubscription</code>). This introduces extra coupling and operational complexity versus Method 1.</p>
<h3>Subscription confirmation</h3>
<blockquote>
<p><strong>Step 1:</strong> In Account B, search for the subscription confirmation message by polling the queue messages.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/29fdb864-c211-478d-aeba-dbdbd0e489b4.png" alt="SQS message with subscription confirmation URL." style="display:block;margin:0 auto" />

<blockquote>
<p>Step 2: Extract the <code>SubscribeURL</code> from the message and enter it in a browser to confirm.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/928f5931-d513-4831-82c9-08367bb5c6be.png" alt="Subscription confirmation result." style="display:block;margin:0 auto" />

<blockquote>
<p>Step 3: Now in Account A, verify that the subscription is confirmed.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/54356be0-642b-4199-ad61-f440c2328134.png" alt="Verify subscription confirmation." style="display:block;margin:0 auto" />

<hr />
<h2>Essential Best Practices</h2>
<h3>1. Use Filter Policies</h3>
<p>Don't make your consumers process every single message. Use <code>SubscriptionFilter</code> to ensure they only get what they need.</p>
<pre><code class="language-typescript">topic.addSubscription(new subs.SqsSubscription(queue, {
  filterPolicy: {
    status: sns.SubscriptionFilter.stringFilter({
      allowlist: ["CREATED", "COMPLETED"],
    }),
  },
}));
</code></pre>
<h3>2. Dead-Letter Queues (DLQs)</h3>
<p>Always attach a DLQ to your SQS queue. If a message fails to process after several retries, it moves to the DLQ instead of blocking the queue.</p>
<pre><code class="language-typescript">const dlq = new sqs.Queue(this, "DeadLetterQueue", {
  fifo: true,
  retentionPeriod: cdk.Duration.days(14),
});

const queue = new sqs.Queue(this, "ConsumerQueue", {
  fifo: true,
  deadLetterQueue: {
    queue: dlq,
    maxReceiveCount: 5, // Move to DLQ after 5 failed attempts
  },
});
</code></pre>
<h3>3. Encryption with KMS</h3>
<p>When using Customer Managed Keys (CMKs) across accounts, KMS permissions depend on the exact service interactions:</p>
<ul>
<li><p><strong>SNS-Encrypted Topics:</strong> Your Publishers (IAM roles or AWS services) must have <code>kms:GenerateDataKey*</code> and <code>kms:Decrypt</code> on the Topic's key to encrypt payloads. The <code>sns.amazonaws.com</code> principal does not need permissions on this key to fan out messages.</p>
</li>
<li><p><strong>SQS-Encrypted Queues:</strong> Because SNS writes to the queue, you must grant the <code>sns.amazonaws.com</code> service principal <code>kms:GenerateDataKey*</code> and <code>kms:Decrypt</code> on the Queue's key. Downstream Consumers also require <code>kms:Decrypt</code> on this key to read the messages.</p>
</li>
<li><p><strong>Cross-Account Setup:</strong> Access requires two-way trust. You must explicitly allow the cross-account actions in both the KMS Key Policy (in the key-owning account) and the IAM Policy (in the calling account). If either is missing, access fails.</p>
</li>
</ul>
<p><a href="https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-least-privilege-policy.html#sqs-use-case-publish-messages-permissions">Grant Amazon SNS KMS permissions to Amazon SNS to publish messages to the queue</a></p>
<pre><code class="language-typescript">// Create the Customer Managed Key (CMK) for the SQS Queue
const queueKey = new kms.Key(this, "QueueKey", {
  enableKeyRotation: true,
  alias: "alias/my-queue-key",
});

// Allow SNS Service Principal to use the Queue's KMS Key
queueKey.addToResourcePolicy(new iam.PolicyStatement({
  effect: iam.Effect.ALLOW,
  principals: [new iam.ServicePrincipal("sns.amazonaws.com")],
  actions: ["kms:GenerateDataKey*", "kms:Decrypt"],
  resources: ["*"], // Evaluates to this specific key
  conditions: {
    // Security Best Practice: Restrict to the specific cross-account topic
    ArnEquals: { "aws:SourceArn": crossAccountTopicArn }
  }
}));
</code></pre>
<p><a href="https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-least-privilege-policy.html#sqs-restrict-transmission-to-topic">Restrict message transmission to a specific Amazon SNS topic</a></p>
<pre><code class="language-typescript">// Create the Encrypted SQS Queue
const queue = new Queue(this, "MyEncryptedQueue", {
  encryption: sqs.QueueEncryption.KMS,
  encryptionMasterKey: queueKey,
});

// Allow the cross-account SNS topic to publish messages to the Queue
queue.addToResourcePolicy(new iam.PolicyStatement({
  effect: iam.Effect.ALLOW,
  principals: [new iam.ServicePrincipal("sns.amazonaws.com")],
  actions: ["sqs:SendMessage"],
  resources: [queue.queueArn],
  conditions: {
    ArnEquals: { "aws:SourceArn": crossAccountTopicArn }
  }
}));
</code></pre>
<blockquote>
<p><strong>Tip:</strong> The <code>ArnEquals: { 'aws:SourceArn': crossAccountTopicArn }</code> condition is crucial to prevent the <a href="https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-least-privilege-policy.html#sqs-confused-deputy-prevention">confused deputy problem</a>, ensuring only your specific topic can use this key and queue.</p>
</blockquote>
<h3>4. FIFO Symmetry</h3>
<p>If you're using FIFO (as in the examples), ensure both the Topic and the Queue are FIFO. A FIFO Topic cannot deliver to a standard Queue, and a standard Topic cannot deliver to a FIFO Queue. Also, make sure your publishers provide a <code>MessageGroupId</code>.</p>
<hr />
<h2>Summary</h2>
<p>Cross-account messaging is a powerful pattern for scaling. By <strong>granting subscription permissions at the Topic level (Method 1)</strong>, you enable a self-service model that empowers consumers and keeps your infrastructure code clean and automated.</p>
<h2>References</h2>
<ul>
<li><a href="https://docs.aws.amazon.com/sns/latest/dg/sns-send-message-to-sqs-cross-account.html">AWS Docs: Sending SNS messages to an SQS queue in a different account</a></li>
</ul>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=xIcNTObKIuc">https://www.youtube.com/watch?v=xIcNTObKIuc</a></p>
]]></content:encoded></item><item><title><![CDATA[Protecting Your Users with NSFWJS]]></title><description><![CDATA[In the modern web, user-generated content (UGC) is everywhere. While it drives engagement, it also brings a major challenge: content moderation. How do you prevent inappropriate images from being uplo]]></description><link>https://blog.hazya.dev/protecting-your-users-with-nsfwjs</link><guid isPermaLink="true">https://blog.hazya.dev/protecting-your-users-with-nsfwjs</guid><category><![CDATA[explicit-content-filtering]]></category><category><![CDATA[indecent-content-filtering]]></category><category><![CDATA[# content moderation]]></category><dc:creator><![CDATA[Hasitha Wickramasinghe]]></dc:creator><pubDate>Thu, 19 Mar 2026 22:04:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/b47cddeb-f4f2-49cc-b31d-8d69ed479b3d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In the modern web, user-generated content (UGC) is everywhere. While it drives engagement, it also brings a major challenge: <strong>content moderation</strong>. How do you prevent inappropriate images from being uploaded or displayed without building a massive, expensive backend infrastructure?</p>
<p>Enter <a href="https://www.npmjs.com/package/nsfwjs"><strong>NSFWJS</strong></a>, a powerful, open-source JavaScript library that brings indecent content checking directly to the client's browser.</p>
<p><a href="https://nsfwjs.com/"><strong>Try the live demo</strong></a></p>
<img src="https://cdn.hashnode.com/uploads/covers/698fb88f7d702cdd2e99a524/8da5f129-781e-4df0-aea9-0c28133dec88.gif" alt="Preview of the NSFWJS demo live classification." style="display:block;margin:0 auto" />

<h2>Why Client-Side Moderation?</h2>
<p>Traditionally, moderation happens on the server. While powerful services like <a href="https://aws.amazon.com/rekognition/">AWS Rekognition</a> or <a href="https://cloud.google.com/vision">Google Cloud Vision</a> are industry standards, they come with trade-offs. You have to upload every user image to their cloud, which introduces latency, privacy concerns, and recurring per-image costs.</p>
<p><strong>NSFWJS flips the script.</strong> By leveraging <a href="https://js.tensorflow.org/">TensorFlow.js</a>, the moderation happens on the user's device. This offers three massive advantages:</p>
<ol>
<li><p><strong>Privacy</strong>: Unlike cloud APIs, the image never has to leave the user's device. This is a huge win for user trust and data privacy compliance.</p>
</li>
<li><p><strong>Scalability</strong>: You offload the heavy lifting (machine learning inference) to the client's hardware. Your servers stay lean, and you avoid the "bill-per-image" model.</p>
</li>
<li><p><strong>Immediate Feedback</strong>: Users get instant results without waiting for a round-trip to a remote server. This enables unique use cases like <strong>real-time video feed or live webcam analysis</strong>, where you can classify several frames per second to provide continuous moderation.</p>
</li>
</ol>
<h2>Categorizing the Web</h2>
<p>NSFWJS doesn't just give you a "Yes/No" answer. It provides probabilities across five distinct classes so that you can choose the level of moderation and intensity you want.</p>
<ul>
<li><p>😐 <strong>Neutral</strong>: Everyday, safe-for-work images.</p>
</li>
<li><p>🎨 <strong>Drawing</strong>: Safe-for-work drawings and anime.</p>
</li>
<li><p>🔥 <strong>Sexy</strong>: Sexually explicit images, but not quite pornography.</p>
</li>
<li><p>🔞 <strong>Hentai</strong>: Hentai and pornographic drawings.</p>
</li>
<li><p>🔞 <strong>Porn</strong>: Pornographic images and sexual acts.</p>
</li>
</ul>
<h2>Getting Started in 30 Seconds</h2>
<p>Integrating NSFWJS is incredibly straightforward. Here’s how you can classify an image element in your web app:</p>
<pre><code class="language-javascript">import * as nsfwjs from "nsfwjs";

// Load the model (MobileNetV2 is the default)
const model = await nsfwjs.load();

// Classify an image element, video, or canvas
const img = document.getElementById("user-upload-preview");
const predictions = await model.classify(img);

console.log("Predictions:", predictions);
</code></pre>
<h3>Advanced: Optimizing for Production (Tree-Shaking)</h3>
<p>If you're building a production app, you might want to keep your bundle as small as possible. By using the <code>nsfwjs/core</code> entry point, you can manually register only the models you need:</p>
<pre><code class="language-javascript">import { load } from "nsfwjs/core";
import { MobileNetV2Model } from "nsfwjs/models/mobilenet_v2";

// Only bundles and loads the specific model you want
const model = await load("MobileNetV2", {
  modelDefinitions: [MobileNetV2Model],
});

const predictions = await model.classify(img);
</code></pre>
<p><em>For a comprehensive, real-world React implementation using Web Workers and local caching in the browser, check out the</em> <a href="https://nsfwjs.com/"><em>Demo</em></a> <em>in the</em> <a href="https://github.com/infinitered/nsfwjs/tree/master/examples/nsfw_demo"><em>GitHub repository</em></a><em>.</em></p>
<h2>Shared Responsibility: Frontend + Backend</h2>
<p>While client-side moderation is a powerful first line of defense, it should <strong>never be your only line of defense</strong>. A savvy user can always bypass frontend code. For a robust system, moderation is a shared responsibility:</p>
<ul>
<li><p><strong>Frontend</strong>: Provides instant feedback to the user, reduces server load, and acts as a filter for the majority of uploads.</p>
</li>
<li><p><strong>Backend</strong>: Acts as the ultimate source of truth. You should always implement a middleware or a server-side hook (using <code>@tensorflow/tfjs-node</code> with <code>NSFWJS</code>) to verify images before they are permanently stored or served to other users.</p>
</li>
</ul>
<h3>Backend Verification (Node.js)</h3>
<p>To ensure your moderation is tamper-proof, you should also verify the content on your server. Here is a quick example of how to classify an image in a Node.js environment:</p>
<pre><code class="language-javascript">const tf = require("@tensorflow/tfjs-node");
const nsfw = require("nsfwjs");

async function verifyImage(imageBuffer) {
  // Load the model (ideally from a local file)
  const model = await nsfw.load(); 

  // Convert buffer to a 3D Tensor
  const image = await tf.node.decodeImage(imageBuffer, 3);

  // Classify and dispose of the tensor to prevent memory leaks
  const predictions = await model.classify(image);
  image.dispose();

  return predictions;
}
</code></pre>
<h2>Performance &amp; Flexibility</h2>
<p>Whether you're building a lightweight mobile site or a heavy-duty web app, NSFWJS has you covered:</p>
<ul>
<li><p><strong>Multiple Models</strong>: Choose between <code>MobileNetV2</code> (fast &amp; small), <code>MobileNetV2Mid</code> (balanced), or <code>InceptionV3</code> (most accurate but also the heaviest).</p>
</li>
<li><p><strong>Tree-Shaking</strong>: Using the <code>nsfwjs/core</code> entry point allows you to bundle only the models you need, keeping your JS payload tiny.</p>
</li>
<li><p><strong>Host Your Own Models</strong>: While models can be bundled into your JS, for optimal performance, <a href="https://github.com/infinitered/nsfwjs?tab=readme-ov-file#host-your-own-model">host the model files</a> directly on your CDN. This allows you to load model binaries directly and enables browsers to cache them separately, reducing your initial JS bundle size.</p>
</li>
<li><p><strong>Node.js Support</strong>: Need to do some server-side checks, too? NSFWJS works perfectly with <code>@tensorflow/tfjs-node</code>.</p>
</li>
<li><p><strong>Backend Selection</strong>: It supports <code>WebGL</code>, <code>WASM</code>, and even the new <code>WebGPU</code> for blazing-fast performance.</p>
</li>
</ul>
<h2>Conclusion</h2>
<p>As developers, we have a responsibility to create safe digital spaces. NSFWJS makes it easy to add a layer of protection to your applications without sacrificing user privacy or breaking the bank on server costs.</p>
<p><strong>Note on Accuracy:</strong> Machine learning is never 100% perfect. NSFWJS is highly accurate (~90-93%), but false positives can happen. If you encounter an image that is misclassified, consider <a href="https://github.com/infinitered/nsfwjs/issues"><strong>reporting it on GitHub</strong></a> so that it can be used to continue improving the model for everyone.</p>
<p>Give it a star on <a href="https://github.com/infinitered/nsfwjs"><strong>GitHub</strong></a> and join the community of contributors making the web a safer place!</p>
]]></content:encoded></item><item><title><![CDATA[Elevate Your UI with Dynamic Text Shadows in React with ShineJS]]></title><description><![CDATA[As developers, we're always looking for that extra "pop" to make our interfaces stand out. Whether you're a fan of neumorphism or just want to add a bit of tactile depth to your typography, static shadows often fall flat. They don't react to the envi...]]></description><link>https://blog.hazya.dev/elevate-your-ui-with-dynamic-text-shadows-in-react-with-shinejs</link><guid isPermaLink="true">https://blog.hazya.dev/elevate-your-ui-with-dynamic-text-shadows-in-react-with-shinejs</guid><category><![CDATA[ShineJS]]></category><category><![CDATA[text-shadow]]></category><category><![CDATA[React]]></category><category><![CDATA[neumorphism]]></category><dc:creator><![CDATA[Hasitha Wickramasinghe]]></dc:creator><pubDate>Mon, 16 Feb 2026 02:29:30 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1771207393967/752f45cb-1dca-4ff3-88f5-ead2df62058f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>As developers, we're always looking for that extra "pop" to make our interfaces stand out. Whether you're a fan of <strong>neumorphism</strong> or just want to add a bit of tactile depth to your typography, static shadows often fall flat. They don't react to the environment, and they certainly don't feel "alive."</p>
<p>That’s why I built <a target="_blank" href="https://github.com/haZya/shinejs"><strong>ShineJS</strong></a>, a modern, lightweight library designed to bring dynamic, light-reactive shadows to your React and Next.js projects.</p>
<p>In this article, I’ll show you how to get started with the core component and provide an interactive playground where you can experiment with <strong>neumorphic-text</strong> effects in real-time.</p>
<h2 id="heading-what-is-shinejs">What is ShineJS?</h2>
<p><a target="_blank" href="https://github.com/haZya/shinejs">ShineJS</a> is an ESM-only TypeScript library based on the initial work from <a target="_blank" href="https://github.com/bigspaceship/shine.js">bigspaceship/shine.js</a> that calculates and injects multi-layered shadows based on a virtual light source. By tracking the mouse position (or a fixed point), it creates a sense of physical presence for your text and UI elements.</p>
<p>It's particularly effective for:</p>
<ul>
<li><p><strong>Neumorphism</strong> aesthetics where soft, directional shadows define the UI.</p>
</li>
<li><p>Hero sections that need a premium, interactive feel.</p>
</li>
<li><p>Visualizing depth in typography-heavy designs.</p>
</li>
</ul>
<h2 id="heading-quick-start-the-component">Quick Start: The <code>&lt;Shine /&gt;</code> Component</h2>
<p>The easiest way to add a shine effect is to use the high-level React component. It handles the ref management and updates automatically.</p>
<h3 id="heading-installation">Installation</h3>
<pre><code class="lang-bash">npm install @hazya/shinejs
</code></pre>
<h3 id="heading-basic-usage">Basic Usage</h3>
<p>Here is a simple example of a heading that follows your mouse:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">import</span> { Shine } <span class="hljs-keyword">from</span> <span class="hljs-string">"@hazya/shinejs/react"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">HeroHeading</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">return</span> (
    <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"bg-slate-100 p-20 flex justify-center"</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">Shine</span>
        <span class="hljs-attr">as</span>=<span class="hljs-string">"h1"</span>
        <span class="hljs-attr">className</span>=<span class="hljs-string">"text-6xl font-black text-slate-200 uppercase tracking-tighter"</span>
        <span class="hljs-attr">options</span>=<span class="hljs-string">{{</span>
          <span class="hljs-attr">light:</span> {
            <span class="hljs-attr">position:</span> "<span class="hljs-attr">followMouse</span>",
            <span class="hljs-attr">intensity:</span> <span class="hljs-attr">1.2</span>,
          },
          <span class="hljs-attr">config:</span> {
            <span class="hljs-attr">blur:</span> <span class="hljs-attr">30</span>,
            <span class="hljs-attr">opacity:</span> <span class="hljs-attr">0.2</span>,
            <span class="hljs-attr">offset:</span> <span class="hljs-attr">0.15</span>,
            <span class="hljs-attr">shadowRGB:</span> { <span class="hljs-attr">r:</span> <span class="hljs-attr">15</span>, <span class="hljs-attr">g:</span> <span class="hljs-attr">23</span>, <span class="hljs-attr">b:</span> <span class="hljs-attr">42</span> }, // <span class="hljs-attr">Slate-900</span>
          },
        }}
      &gt;</span>
        Dynamic Depth
      <span class="hljs-tag">&lt;/<span class="hljs-name">Shine</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span>
  );
}
</code></pre>
<iframe src="https://stackblitz.com/github/haZya/shinejs-examples/tree/main/shinejs-simple-demo-react?embed=1&amp;file=src%2FApp.tsx&amp;hideExplorer=1&amp;hideNavigation=1&amp;view=preview" style="width:100%;height:400px;border:0"></iframe>

<h2 id="heading-interactive-playground">Interactive Playground</h2>
<p>I believe the best way to understand a tool is to break it. Below is a live playground that showcases the ShineJS options.</p>
<p>You can tweak the <strong>intensity</strong>, <strong>blur</strong>, and <strong>offset,</strong> etc., to see how the shadow layers interact. This playground uses the <code>useShine</code> hook under the hood to give you full control over the rendering logic.</p>
<iframe src="https://stackblitz.com/github/haZya/shinejs-examples/tree/main/shinejs-playground-demo-react?embed=1&amp;file=src%2FApp.tsx&amp;hideExplorer=1&amp;hideNavigation=1&amp;view=preview" style="width:100%;height:950px;border:0"></iframe>

<pre><code class="lang-javascript"><span class="hljs-string">"use client"</span>;

<span class="hljs-keyword">import</span> { Shine } <span class="hljs-keyword">from</span> <span class="hljs-string">"@hazya/shinejs/react"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">PlaygroundStarter</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">return</span> (
    <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Shine</span>
      <span class="hljs-attr">as</span>=<span class="hljs-string">"h1"</span>
      <span class="hljs-attr">options</span>=<span class="hljs-string">{{</span>
        <span class="hljs-attr">light:</span> {
          <span class="hljs-attr">position:</span> "<span class="hljs-attr">followMouse</span>",
          <span class="hljs-attr">intensity:</span> <span class="hljs-attr">1</span>,
        },
        <span class="hljs-attr">config:</span> {
          <span class="hljs-attr">numSteps:</span> <span class="hljs-attr">5</span>,
          <span class="hljs-attr">opacity:</span> <span class="hljs-attr">0.15</span>,
          <span class="hljs-attr">opacityPow:</span> <span class="hljs-attr">1.2</span>,
          <span class="hljs-attr">offset:</span> <span class="hljs-attr">0.15</span>,
          <span class="hljs-attr">offsetPow:</span> <span class="hljs-attr">1.8</span>,
          <span class="hljs-attr">blur:</span> <span class="hljs-attr">40</span>,
          <span class="hljs-attr">blurPow:</span> <span class="hljs-attr">1</span>,
          <span class="hljs-attr">shadowRGB:</span> { <span class="hljs-attr">r:</span> <span class="hljs-attr">0</span>, <span class="hljs-attr">g:</span> <span class="hljs-attr">0</span>, <span class="hljs-attr">b:</span> <span class="hljs-attr">0</span> },
        },
      }}
    &gt;</span>
      Shine Playground
    <span class="hljs-tag">&lt;/<span class="hljs-name">Shine</span>&gt;</span></span>
  );
}
</code></pre>
<h3 id="heading-things-to-try">Things to Try:</h3>
<ul>
<li><p><strong>Shadow Color:</strong> Change the <code>shadowRGB</code> to match your background for a true neumorphic look.</p>
</li>
<li><p><strong>Opacity Power:</strong> Crank up the <code>opacityPow</code> to see how it affects the falloff of the shadow layers.</p>
</li>
<li><p><strong>Light Position:</strong> Switch from <code>followMouse</code> to a <code>fixed</code> point to simulate a static light source in your UI.</p>
</li>
</ul>
<h2 id="heading-typography-and-localization">Typography and Localization</h2>
<p>One of the strengths of ShineJS is its adaptability. Because it works with standard CSS text-shadows and box-shadows, it respects your typography choices, including font-family and font-weight.</p>
<p>In this next example, you can see how the effect maintains its integrity across different font families and languages. Whether it's bold Sans-Serif or elegant Serif, the shadows wrap perfectly around every glyph.</p>
<iframe src="https://stackblitz.com/github/haZya/shinejs-examples/tree/main/shinejs-typography-demo-react?embed=1&amp;file=src%2FApp.tsx&amp;hideExplorer=1&amp;hideNavigation=1&amp;view=preview" style="width:100%;height:740px;border:0"></iframe>

<pre><code class="lang-javascript"><span class="hljs-string">"use client"</span>;

<span class="hljs-keyword">import</span> { Shine } <span class="hljs-keyword">from</span> <span class="hljs-string">"@hazya/shinejs/react"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">TypographyExample</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">return</span> (
    <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Shine</span>
      <span class="hljs-attr">as</span>=<span class="hljs-string">"h1"</span>
      <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span>
        <span class="hljs-attr">fontFamily:</span> "<span class="hljs-attr">Georgia</span>, '<span class="hljs-attr">Times</span> <span class="hljs-attr">New</span> <span class="hljs-attr">Roman</span>', <span class="hljs-attr">serif</span>",
        <span class="hljs-attr">fontWeight:</span> <span class="hljs-attr">700</span>,
        <span class="hljs-attr">fontStyle:</span> "<span class="hljs-attr">italic</span>",
      }}
      <span class="hljs-attr">options</span>=<span class="hljs-string">{{</span>
        <span class="hljs-attr">light:</span> { <span class="hljs-attr">position:</span> "<span class="hljs-attr">followMouse</span>", <span class="hljs-attr">intensity:</span> <span class="hljs-attr">1.15</span> },
        <span class="hljs-attr">config:</span> {
          <span class="hljs-attr">blur:</span> <span class="hljs-attr">38</span>,
          <span class="hljs-attr">offset:</span> <span class="hljs-attr">0.12</span>,
          <span class="hljs-attr">opacity:</span> <span class="hljs-attr">0.32</span>,
          <span class="hljs-attr">shadowRGB:</span> { <span class="hljs-attr">r:</span> <span class="hljs-attr">17</span>, <span class="hljs-attr">g:</span> <span class="hljs-attr">24</span>, <span class="hljs-attr">b:</span> <span class="hljs-attr">39</span> },
        },
      }}
    &gt;</span>
      Typography Shine
    <span class="hljs-tag">&lt;/<span class="hljs-name">Shine</span>&gt;</span></span>
  );
}
</code></pre>
<h3 id="heading-why-this-matters-for-neumorphism">Why this matters for Neumorphism</h3>
<p>Neumorphic design relies heavily on the relationship between the light source and the "material" of the UI. By adjusting the typography options alongside ShineJS parameters, you can ensure that your <strong>neumorphic text</strong> remains readable and visually consistent across all device types and screen resolutions.</p>
<h2 id="heading-built-with-accessibility-in-mind">Built with Accessibility in Mind</h2>
<p>Dynamic text effects can sometimes be a nightmare for screen readers if not handled correctly. When ShineJS splits your text into individual characters to apply granular shadows, it automatically manages the ARIA attributes for you.</p>
<p>The original element receives an <code>aria-label</code> containing the full text, while the internal split-text structure is marked with <code>aria-hidden="true"</code>. This ensures that screen readers see a single, clean string of text rather than a fragmented series of letters, keeping your <strong>neumorphic text</strong> accessible to everyone.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>ShineJS is designed to be unobtrusive yet impactful. It doesn't require heavy canvas rendering or complex WebGL setups. It uses smart CSS injection that follows the physics of light.</p>
<p>If you're building a landing page or a creative portfolio, give <code>@hazya/shinejs</code> a try.</p>
<ul>
<li><p><strong>Documentation:</strong> <a target="_blank" href="http://shinejs.vercel.app/docs">shinejs.vercel.app/docs</a></p>
</li>
<li><p><strong>GitHub:</strong> <a target="_blank" href="https://github.com/haZya/shinejs">haZya/shinejs</a></p>
</li>
</ul>
<p>Happy coding!</p>
]]></content:encoded></item></channel></rss>