diff --git a/README.md b/README.md
index 3e9ecbd..2d919ca 100644
--- a/README.md
+++ b/README.md
@@ -2,102 +2,85 @@
-
+
-# MildStack
-
-> A lightweight, local-first AWS emulator built for developers. The best localstack alternative.
-
-MildStack is an open-source project that helps you run and test AWS-like services locally with a focus on speed, simplicity, and low resource usage.
-
-It is designed to be a practical alternative for local cloud development, without unnecessary overhead.
-
-## Why MildStack?
-
-Working with AWS-based applications locally can be slow, heavy, or fragmented.
-
-MildStack aims to make that experience better by being:
-
-- lightweight
-- fast
-- developer-friendly
-- open-source
-- easy to extend
-- suitable for local development workflows
-
-## What it is
+
+ The Lightweight, Drop-in Replacement for LocalStack.
+ Fast, Open Source, and Developer-First.
+
-MildStack is being built as a small ecosystem around a core emulator.
+
+ Website •
+ Key Features •
+ Supported Services •
+ Community
+
-The project currently includes:
+---
-- a Go-based core for the emulator and API
-- a CLI for local control and startup
-- a desktop app for a more visual experience and resource browsing
-- a web presence for documentation and project info
+## ⚡️ What is MildStack?
-## Project goals
+MildStack is a high-performance, local-first AWS emulator designed to streamline your cloud development workflow. Unlike heavy alternatives that require Docker and significant system resources, MildStack is built in **Go** for maximum efficiency and speed.
-MildStack is intended to be:
+Stop waiting for containers to spin up. Start building instantly with a local cloud that feels "mild" on your CPU but "spicy" on productivity.
-- a local AWS-like emulator
-- simple to run and use
-- performant and memory-efficient
-- modular by design
-- consistent across services
-- easy to evolve over time
+## ✨ Why MildStack?
-## Tech stack
+- **🚀 Instant-On**: No Docker required. MildStack runs as a native binary, starting in milliseconds.
+- **🖥️ Desktop App**: A beautiful, intuitive UI to browse S3 buckets, query DynamoDB tables, and monitor SQS queues without leaving your IDE.
+- **🍃 Ultra-Lightweight**: Minimal RAM and CPU footprint. Keep your machine cool while simulating complex cloud architectures.
+- **🔌 Drop-in Compatibility**: Works seamlessly with official AWS SDKs and CLI. Just change your endpoint URL.
+- **📡 Offline-First**: Build and test your cloud applications on a plane, a train, or anywhere without an internet connection.
+- **💰 100% Free**: No "Pro" tiers for basic features. Everything you need for local development, open-source and free.
-The project is centered around:
+## 🛠 Supported Services
-- **Go** for the core runtime, API, and CLI
-- **Gin** for the HTTP API
-- **Charm** for the terminal UI and CLI experience
-- **Electron** for the desktop app
-- **React** for the website and docs
+MildStack is rapidly evolving. We currently provide robust support for core AWS services:
-## Design principles
+| Service | Status | Features |
+| :--- | :--- | :--- |
+| **S3** | ✅ Active | Bucket management, Multipart uploads, Metadata support |
+| **DynamoDB** | ✅ Active | Tables, GSI/LSI support, Rich querying & filtering |
+| **SQS** | ✅ Active | Message queues, DLQ redrive, FIFO support |
+| **SNS** | 📅 Planned | Topic publishing, basic subscriptions |
+| **Lambda** | 📅 Planned | Local execution of serverless functions |
+| **EventBridge** | 📅 Planned | Event-driven architecture simulation |
-The core of the project follows:
+## 📦 The Ecosystem
-- **Domain-Driven Design**
-- **Clean Architecture**
-- **SOLID principles**
+MildStack isn't just an emulator; it's a complete development environment:
-The goal is to keep the emulator core independent from frameworks and easy to maintain as new services are added.
+### 1. The Core Engine
+Written in Go, our core provides a high-concurrency, low-latency API that mimics AWS service behavior with precision.
-## Philosophy
+### 2. The MildStack CLI
+A modern, terminal-based control center (powered by Charm/BubbleTea) to manage your local instances, view logs, and monitor service health.
-MildStack is built around a few simple ideas:
+### 3. The Desktop Browser
+An Electron-powered visual console that gives you a "Production-like" experience for inspecting your local resources. Browse objects, edit items, and peek at messages with ease.
-- local-first
-- developer-first
-- performance-oriented
-- minimal overhead
-- clear architecture
-- open-source friendly
+---
-## Current status
+## 🗺 Roadmap
-MildStack is still in early development.
+Our goal is to cover the 80% of AWS services used in 95% of applications. Check our [Roadmap](https://mildstack.dev/roadmap) to see what's coming next, including Lambda support, IAM simulation, and more.
-This README is intentionally lightweight and provisional until the project has proper installation docs, usage guides, and service-specific documentation.
+## 🤝 Contributing
-## Contributing
+We love contributors! Whether you're fixing a bug, adding a new service, or improving the documentation, your help is welcome.
-Contributions are welcome.
+1. Check out our [Contribution Guidelines](CONTRIBUTING.md).
+2. Join our [Discord community](https://discord.gg/your-invite) to discuss ideas.
+3. Spread the word! 🌟
-If you want to help, you can:
+## 📄 License
-- suggest services to emulate first
-- review architecture decisions
-- improve documentation
-- test the project locally
-- help build core features
+MildStack is released under the **MIT License**. Build freely.
-## License
+---
-MIT.
+
+ Built with ❤️ for developers by Michel and the community.
+
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index a7691a9..a2fb56d 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -1,153 +1,56 @@
-# MildStack App
+# 🖥️ MildStack Desktop
-MildStack App is an open source desktop app for browsing and inspecting resources from MildStack, a localstack-like local AWS environment.
+**The Visual Console for your Local Cloud.**
-The project is currently in its foundation stage. It starts from an electron-vite Electron,
-React, and TypeScript application and is being shaped into a local developer console for
-MildStack workflows.
+MildStack Desktop is a cross-platform companion app for [MildStack](https://github.com/michasdev/mildstack). It provides an intuitive, production-grade interface to manage, browse, and inspect your local AWS-compatible resources without touching the command line.
-## What it is
+
+
+
-MildStack App gives developers a desktop UI for local MildStack resources so they do not
-need to reach for raw command-line checks for every inspection task.
+## ✨ Features
-The version 1 target is local MildStack resource browsing. For version 1, real AWS account
-management is not supported for the version 1 local MildStack browsing scope.
+- **📂 S3 Explorer**: Browse buckets, navigate prefixes (folders), upload objects, and inspect metadata with a modern file-manager experience.
+- **📊 DynamoDB Browser**: Query tables using a rich UI, filter items, edit attributes, and visualize your data structures instantly.
+- **📩 SQS Monitor**: Peek at messages, monitor queue depths, and manage Dead Letter Queues (DLQ) with ease.
+- **🛠 Instance Management**: Start, stop, and switch between multiple MildStack runtime instances directly from the UI.
-## Scope
+## 🏗 Built for Developers
-Current scope:
+MildStack Desktop is built with a modern stack optimized for performance and safety:
-- Cross-platform Electron desktop app using React and TypeScript.
-- Contributor documentation, local setup, and build scripts from the existing project.
-- A strict Electron boundary where privileged MildStack communication belongs outside the
- renderer.
+- **Electron & Vite**: Fast startup and smooth transitions.
+- **React & TypeScript**: Robust, type-safe UI components.
+- **IPC Safety**: Privileged communication with the MildStack core is handled outside the renderer process for maximum security.
+- **Clean Architecture**: Decoupled features that allow for rapid extension to new AWS services.
-Deferred roadmap work, not current setup:
+## 🗺 Roadmap
-- Tailwind CSS renderer styling.
-- CossUI setup through the planned shadcn CLI path.
-- Multipage desktop navigation.
-- Typed MildStack IPC from renderer to preload to main.
-- Generic MildStack resource browsing.
-- Dedicated S3 explorer.
-- Dedicated DynamoDB explorer.
+We are continuously adding new capabilities to the desktop experience:
-No current local setup step depends on these deferred items.
+- [x] S3 Bucket & Object Browsing
+- [x] DynamoDB Table & Item Exploration
+- [x] SQS Message Peeking
+- [ ] Lambda Log Stream Monitoring
+- [ ] IAM Policy Visualizer
+- [ ] CloudFormation Stack Viewer
-## How it works
+---
-The app is split across the standard Electron process boundaries:
+## 🤝 Contributing
-- `src/main/` owns the Electron main process, native window lifecycle, privileged IPC handlers,
- and future MildStack command coordination.
-- `src/preload/` owns the safe bridge exposed to the renderer.
-- `src/renderer/src/` owns the React and TypeScript UI that runs in the renderer process.
+We welcome contributions to the desktop app! Whether it's improving the UI, adding new service browsers, or fixing bugs.
-Renderer code must not import Electron directly or call shell APIs directly. MildStack
-communication will be implemented behind Electron main/preload APIs in a later phase, then
-exposed to React through a typed renderer-safe contract.
+1. Fork the repository.
+2. Check the [Local Setup Guide](https://github.com/michasdev/mildstack/blob/main/apps/desktop/CONTRIBUTING.md) (coming soon).
+3. Submit a PR!
-## Local setup
+## 📄 License
-Install dependencies:
+MIT. Part of the MildStack ecosystem.
-```bash
-npm install
-```
+---
-Start the local Electron development app:
-
-```bash
-npm run dev
-```
-
-Preview the built Electron app:
-
-```bash
-npm run start
-```
-
-## Available scripts
-
-The current `package.json` scripts are:
-
-- `npm run format` - Format the repository with Prettier.
-- `npm run lint` - Run ESLint.
-- `npm run typecheck:node` - Typecheck Electron main, preload, and build config code.
-- `npm run typecheck:web` - Typecheck the React renderer and preload type declarations.
-- `npm run typecheck` - Run node and web typechecks.
-- `npm run start` - Preview the Electron app with electron-vite.
-- `npm run dev` - Start the Electron development app.
-- `npm run build` - Typecheck and build with electron-vite.
-- `npm run postinstall` - Install Electron Builder app dependencies.
-- `npm run build:unpack` - Build and produce an unpacked Electron Builder output.
-- `npm run build:win` - Build a Windows package.
-- `npm run build:mac` - Build a macOS package.
-- `npm run build:linux` - Build Linux packages.
-
-## Project structure
-
-```text
-src/
- main/ Electron main process
- preload/ Renderer-safe preload bridge
- renderer/
- index.html Renderer HTML shell
- src/ React renderer source
-resources/ Desktop app resources
-build/ Packaging support files
-```
-
-Configuration lives in:
-
-- `electron.vite.config.ts` - Electron Vite main, preload, and renderer build config.
-- `tsconfig.node.json` - TypeScript config for Electron-side code.
-- `tsconfig.web.json` - TypeScript config for renderer-side code.
-- `electron-builder.yml` - Cross-platform packaging config.
-
-## Roadmap
-
-Version 1 is planned as a sequence of foundation and browsing work:
-
-1. Project and renderer foundation: README, renderer structure, and renderer aliases.
-2. Tailwind and CossUI setup: planned styling and component foundation.
-3. Desktop app shell and navigation: planned multipage app shell.
-4. Typed MildStack Electron API: planned renderer to preload to main communication.
-5. Generic resource browser: planned local MildStack service and resource inspection.
-6. S3 explorer: planned dedicated bucket, prefix, object, and metadata browsing.
-7. DynamoDB explorer: planned dedicated table, metadata, and item browsing.
-
-These roadmap items describe planned work, not current completed functionality.
-
-## Contributing
-
-MildStack App is intended to be public open source software. Contributions should preserve
-the desktop Electron target, keep renderer code browser-safe, and document only behavior that
-exists in the current codebase unless it is clearly marked as planned roadmap work.
-
-Use the current npm scripts for validation before submitting changes:
-
-```bash
-npm run typecheck
-npm run lint
-```
-
-## S3 smoke test
-
-The app now includes a smoke runner for the MildStack S3 surface:
-
-```bash
-npm run s3:smoke
-```
-
-By default it targets the local MildStack runtime API at
-`http://127.0.0.1:4566/api/v1/runtime/services/s3`. Override that with
-`MILDSTACK_API_BASE_URL` if your server is running elsewhere.
-
-An optional `--native` mode is also available for S3-compatible endpoints via
-`@aws-sdk/client-s3`:
-
-```bash
-npm run s3:smoke -- --native
-```
+
+ Stop guessing. Start seeing. 🚀
+
diff --git a/apps/desktop/scripts/dynamo-smoke.cjs b/apps/desktop/scripts/dynamo-smoke.cjs
index 2f9b2ac..cf23c4e 100644
--- a/apps/desktop/scripts/dynamo-smoke.cjs
+++ b/apps/desktop/scripts/dynamo-smoke.cjs
@@ -42,7 +42,7 @@ main().catch((error) => {
});
async function main() {
- const endpoint = process.env.MILDSTACK_DYNAMODB_ENDPOINT || process.env.AWS_DYNAMODB_ENDPOINT || `http://localhost:${port}`;
+ const endpoint = process.env.MILDSTACK_DYNAMODB_ENDPOINT || `http://localhost:${port}`;
console.log(`Running AWS SDK smoke mode against ${endpoint}`);
const client = new DynamoDBClient({
diff --git a/apps/desktop/scripts/s3-smoke.cjs b/apps/desktop/scripts/s3-smoke.cjs
index a6be95e..247e5b1 100644
--- a/apps/desktop/scripts/s3-smoke.cjs
+++ b/apps/desktop/scripts/s3-smoke.cjs
@@ -43,7 +43,7 @@ function expectDefined(actual, message) {
}
async function main() {
- const endpoint = process.env.MILDSTACK_S3_ENDPOINT || process.env.AWS_S3_ENDPOINT || `http://localhost:${port}`;
+ const endpoint = process.env.MILDSTACK_S3_ENDPOINT || `http://localhost:${port}`;
console.log(`Running S3 behavioral validation against ${endpoint}`);
const client = new S3Client({
diff --git a/apps/desktop/scripts/sqs-smoke.cjs b/apps/desktop/scripts/sqs-smoke.cjs
index deda7ef..37a4af2 100644
--- a/apps/desktop/scripts/sqs-smoke.cjs
+++ b/apps/desktop/scripts/sqs-smoke.cjs
@@ -45,7 +45,7 @@ main().catch((error) => {
});
async function main() {
- const endpoint = process.env.MILDSTACK_SQS_ENDPOINT || process.env.AWS_SQS_ENDPOINT || `http://localhost:${port}`;
+ const endpoint = process.env.MILDSTACK_SQS_ENDPOINT || `http://localhost:${port}`;
console.log(`Running AWS SDK smoke mode against ${endpoint}`);
const client = new SQSClient({
diff --git a/apps/desktop/src/main/dynamodb-ipc.ts b/apps/desktop/src/main/dynamodb-ipc.ts
index 3f12cad..bb62ac2 100644
--- a/apps/desktop/src/main/dynamodb-ipc.ts
+++ b/apps/desktop/src/main/dynamodb-ipc.ts
@@ -1,4 +1,4 @@
-import { getActiveInstancePort } from './instance-state'
+import { resolveLocalEndpoint } from './local-endpoint'
import { registerValidatedHandler } from './ipc-middleware'
import {
DynamoDBClient,
@@ -269,8 +269,7 @@ function getClient(region = 'us-east-1'): DynamoDBClient {
}
function resolveDynamoDBEndpoint(): string {
- const port = getActiveInstancePort()
- return process.env.MILDSTACK_DYNAMODB_ENDPOINT || process.env.AWS_DYNAMODB_ENDPOINT || `http://127.0.0.1:${port}`
+ return resolveLocalEndpoint('dynamodb')
}
function normalizeRegion(region?: string): string {
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
index 900ba4b..2c08efe 100644
--- a/apps/desktop/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -5,6 +5,7 @@ import { autoUpdater } from 'electron-updater'
import icon from '../../build/icon.png?asset'
import { registerS3IpcHandlers } from './s3-ipc'
import { registerDynamoDBIpcHandlers } from './dynamodb-ipc'
+import { registerSQSIpcHandlers } from './sqs-ipc'
import { registerMildStackIpcHandlers } from './mildstack-ipc'
// Set app name for macOS Dock and Menu Bar as early as possible
@@ -82,6 +83,7 @@ app.whenReady().then(() => {
ipcMain.on('ping', () => console.log('pong'))
registerS3IpcHandlers()
registerDynamoDBIpcHandlers()
+ registerSQSIpcHandlers()
registerMildStackIpcHandlers()
createWindow()
diff --git a/apps/desktop/src/main/local-endpoint.ts b/apps/desktop/src/main/local-endpoint.ts
new file mode 100644
index 0000000..b3e8c4d
--- /dev/null
+++ b/apps/desktop/src/main/local-endpoint.ts
@@ -0,0 +1,13 @@
+import { getActiveInstancePort } from './instance-state'
+
+export function resolveLocalEndpoint(serviceName: string): string {
+ const normalizedServiceName = serviceName.trim().toUpperCase()
+ const envKey = `MILDSTACK_${normalizedServiceName}_ENDPOINT`
+ const override = process.env[envKey]?.trim()
+ if (override) {
+ return override
+ }
+
+ const port = getActiveInstancePort()
+ return `http://localhost:${port}`
+}
diff --git a/apps/desktop/src/main/s3-ipc.ts b/apps/desktop/src/main/s3-ipc.ts
index 1a5ad4c..3a4397e 100644
--- a/apps/desktop/src/main/s3-ipc.ts
+++ b/apps/desktop/src/main/s3-ipc.ts
@@ -1,4 +1,4 @@
-import { getActiveInstancePort } from './instance-state'
+import { resolveLocalEndpoint } from './local-endpoint'
import { registerValidatedHandler } from './ipc-middleware'
import {
S3Client,
@@ -212,8 +212,7 @@ function getClient(region = 'us-east-1'): S3Client {
}
function resolveS3Endpoint(): string {
- const port = getActiveInstancePort()
- return process.env.MILDSTACK_S3_ENDPOINT || process.env.AWS_S3_ENDPOINT || `http://127.0.0.1:${port}`
+ return resolveLocalEndpoint('s3')
}
function normalizeRegion(region?: string): string {
diff --git a/apps/desktop/src/main/sqs-ipc.ts b/apps/desktop/src/main/sqs-ipc.ts
new file mode 100644
index 0000000..cba1fe9
--- /dev/null
+++ b/apps/desktop/src/main/sqs-ipc.ts
@@ -0,0 +1,186 @@
+import { resolveLocalEndpoint } from './local-endpoint'
+import { registerValidatedHandler } from './ipc-middleware'
+import {
+ SQSClient,
+ ListQueuesCommand,
+ CreateQueueCommand,
+ DeleteQueueCommand,
+ GetQueueAttributesCommand,
+ SetQueueAttributesCommand,
+ SendMessageCommand,
+ ReceiveMessageCommand,
+ DeleteMessageCommand,
+ PurgeQueueCommand
+} from '@aws-sdk/client-sqs'
+
+type SQSClientCacheEntry = {
+ region: string
+ endpoint: string
+ client: SQSClient
+}
+
+const clientCache = new Map()
+
+export function registerSQSIpcHandlers(): void {
+ registerValidatedHandler('sqs:listQueues', async (_event, args: { region?: string }) => {
+ const client = getClient(args.region)
+ const response = await client.send(new ListQueuesCommand({}))
+ const queueUrls = response.QueueUrls ?? []
+
+ // Fetch attributes for each queue to build the summary
+ const summaries = await Promise.all(
+ queueUrls.map(async (url) => {
+ const attrResponse = await client.send(new GetQueueAttributesCommand({
+ QueueUrl: url,
+ AttributeNames: ['All']
+ }))
+ const attributes = attrResponse.Attributes || {}
+
+ // Extract queue name from URL (last part after /)
+ const queueName = url.split('/').pop() || url
+
+ return {
+ QueueUrl: url,
+ QueueName: queueName,
+ IsFifo: queueName.endsWith('.fifo'),
+ MessagesAvailable: parseInt(attributes.ApproximateNumberOfMessages || '0', 10),
+ MessagesDelayed: parseInt(attributes.ApproximateNumberOfMessagesDelayed || '0', 10),
+ MessagesInvisible: parseInt(attributes.ApproximateNumberOfMessagesNotVisible || '0', 10),
+ VisibilityTimeout: parseInt(attributes.VisibilityTimeout || '30', 10)
+ }
+ })
+ )
+
+ return summaries
+ })
+
+ registerValidatedHandler('sqs:createQueue', async (_event, args: { queueName: string; isFifo?: boolean; region?: string }) => {
+ const attributes: Record = {}
+ let name = args.queueName
+
+ if (args.isFifo) {
+ if (!name.endsWith('.fifo')) name += '.fifo'
+ attributes.FifoQueue = 'true'
+ }
+
+ const response = await getClient(args.region).send(new CreateQueueCommand({
+ QueueName: name,
+ Attributes: Object.keys(attributes).length > 0 ? attributes : undefined
+ }))
+ return response.QueueUrl
+ })
+
+ registerValidatedHandler('sqs:deleteQueue', async (_event, args: { queueUrl: string; region?: string }) => {
+ await getClient(args.region).send(new DeleteQueueCommand({ QueueUrl: args.queueUrl }))
+ return null
+ })
+
+ registerValidatedHandler('sqs:getQueueAttributes', async (_event, args: { queueUrl: string; region?: string }) => {
+ const response = await getClient(args.region).send(new GetQueueAttributesCommand({
+ QueueUrl: args.queueUrl,
+ AttributeNames: ['All']
+ }))
+ return response.Attributes || {}
+ })
+
+ registerValidatedHandler('sqs:setQueueAttributes', async (_event, args: { queueUrl: string; attributes: Record; region?: string }) => {
+ await getClient(args.region).send(new SetQueueAttributesCommand({
+ QueueUrl: args.queueUrl,
+ Attributes: args.attributes
+ }))
+ return null
+ })
+
+ registerValidatedHandler('sqs:purgeQueue', async (_event, args: { queueUrl: string; region?: string }) => {
+ await getClient(args.region).send(new PurgeQueueCommand({ QueueUrl: args.queueUrl }))
+ return null
+ })
+
+ registerValidatedHandler('sqs:sendMessage', async (_event, args: {
+ queueUrl: string
+ body: string
+ delaySeconds?: number
+ messageGroupId?: string
+ messageDeduplicationId?: string
+ messageAttributes?: Record
+ region?: string
+ }) => {
+ const response = await getClient(args.region).send(new SendMessageCommand({
+ QueueUrl: args.queueUrl,
+ MessageBody: args.body,
+ DelaySeconds: args.delaySeconds,
+ MessageGroupId: args.messageGroupId,
+ MessageDeduplicationId: args.messageDeduplicationId,
+ MessageAttributes: args.messageAttributes
+ }))
+ return response.MessageId
+ })
+
+ registerValidatedHandler('sqs:receiveMessages', async (_event, args: {
+ queueUrl: string
+ maxMessages?: number
+ waitTimeSeconds?: number
+ region?: string
+ }) => {
+ const response = await getClient(args.region).send(new ReceiveMessageCommand({
+ QueueUrl: args.queueUrl,
+ MaxNumberOfMessages: args.maxMessages || 1,
+ WaitTimeSeconds: args.waitTimeSeconds || 0,
+ AttributeNames: ['All'],
+ MessageAttributeNames: ['All']
+ }))
+
+ return (response.Messages || []).map(msg => ({
+ MessageId: msg.MessageId,
+ ReceiptHandle: msg.ReceiptHandle,
+ MD5OfBody: msg.MD5OfBody,
+ Body: msg.Body,
+ Attributes: msg.Attributes,
+ MessageAttributes: msg.MessageAttributes
+ }))
+ })
+
+ registerValidatedHandler('sqs:deleteMessage', async (_event, args: { queueUrl: string; receiptHandle: string; region?: string }) => {
+ await getClient(args.region).send(new DeleteMessageCommand({
+ QueueUrl: args.queueUrl,
+ ReceiptHandle: args.receiptHandle
+ }))
+ return null
+ })
+}
+
+function getClient(region = 'us-east-1'): SQSClient {
+ const normalizedRegion = normalizeRegion(region)
+ const endpoint = resolveSQSEndpoint()
+ const cacheKey = `${normalizedRegion}:${endpoint}`
+ const cached = clientCache.get(cacheKey)
+ if (cached) {
+ return cached.client
+ }
+
+ const client = new SQSClient({
+ region: normalizedRegion,
+ endpoint,
+ credentials: {
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'test',
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'test'
+ }
+ })
+
+ clientCache.set(cacheKey, {
+ region: normalizedRegion,
+ endpoint,
+ client
+ })
+
+ return client
+}
+
+function resolveSQSEndpoint(): string {
+ return resolveLocalEndpoint('sqs')
+}
+
+function normalizeRegion(region?: string): string {
+ const trimmed = region?.trim()
+ return trimmed || 'us-east-1'
+}
diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts
index a424520..b362b61 100644
--- a/apps/desktop/src/preload/index.d.ts
+++ b/apps/desktop/src/preload/index.d.ts
@@ -61,6 +61,31 @@ interface DynamoDBBrowserApi {
getItem(tableName: string, key: any, region?: string): Promise
}
+interface SQSBrowserApi {
+ listQueues(region?: string): Promise
+ createQueue(queueName: string, isFifo?: boolean, region?: string): Promise
+ deleteQueue(queueUrl: string, region?: string): Promise
+ getQueueAttributes(queueUrl: string, region?: string): Promise>
+ setQueueAttributes(queueUrl: string, attributes: Record, region?: string): Promise
+ purgeQueue(queueUrl: string, region?: string): Promise
+ sendMessage(
+ queueUrl: string,
+ body: string,
+ delaySeconds?: number,
+ messageGroupId?: string,
+ messageDeduplicationId?: string,
+ messageAttributes?: Record,
+ region?: string
+ ): Promise
+ receiveMessages(
+ queueUrl: string,
+ maxMessages?: number,
+ waitTimeSeconds?: number,
+ region?: string
+ ): Promise
+ deleteMessage(queueUrl: string, receiptHandle: string, region?: string): Promise
+}
+
interface InstanceApi {
setSelected(port: number): Promise
}
@@ -98,6 +123,7 @@ declare global {
api: {
s3: S3BrowserApi
dynamodb: DynamoDBBrowserApi
+ sqs: SQSBrowserApi
instance: InstanceApi
mildstack: MildStackApi
}
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index 2415764..770ef28 100644
--- a/apps/desktop/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -63,6 +63,31 @@ interface DynamoDBBrowserApi {
getItem(tableName: string, key: any, region?: string): Promise
}
+interface SQSBrowserApi {
+ listQueues(region?: string): Promise
+ createQueue(queueName: string, isFifo?: boolean, region?: string): Promise
+ deleteQueue(queueUrl: string, region?: string): Promise
+ getQueueAttributes(queueUrl: string, region?: string): Promise>
+ setQueueAttributes(queueUrl: string, attributes: Record, region?: string): Promise
+ purgeQueue(queueUrl: string, region?: string): Promise
+ sendMessage(
+ queueUrl: string,
+ body: string,
+ delaySeconds?: number,
+ messageGroupId?: string,
+ messageDeduplicationId?: string,
+ messageAttributes?: Record,
+ region?: string
+ ): Promise
+ receiveMessages(
+ queueUrl: string,
+ maxMessages?: number,
+ waitTimeSeconds?: number,
+ region?: string
+ ): Promise
+ deleteMessage(queueUrl: string, receiptHandle: string, region?: string): Promise
+}
+
interface InstanceApi {
setSelected(port: number): Promise
}
@@ -95,7 +120,7 @@ interface MildStackApi {
}
// Custom APIs for renderer
-const api: { s3: S3BrowserApi; dynamodb: DynamoDBBrowserApi; instance: InstanceApi; mildstack: MildStackApi } = {
+const api: { s3: S3BrowserApi; dynamodb: DynamoDBBrowserApi; sqs: SQSBrowserApi; instance: InstanceApi; mildstack: MildStackApi } = {
s3: {
listBuckets: (region) => ipcRenderer.invoke('s3:listBuckets', { region }),
createBucket: (name, region) => ipcRenderer.invoke('s3:createBucket', { name, region }),
@@ -125,6 +150,19 @@ const api: { s3: S3BrowserApi; dynamodb: DynamoDBBrowserApi; instance: InstanceA
getItem: (tableName, key, region) =>
ipcRenderer.invoke('dynamodb:getItem', { tableName, key, region })
},
+ sqs: {
+ listQueues: (region) => ipcRenderer.invoke('sqs:listQueues', { region }),
+ createQueue: (queueName, isFifo, region) => ipcRenderer.invoke('sqs:createQueue', { queueName, isFifo, region }),
+ deleteQueue: (queueUrl, region) => ipcRenderer.invoke('sqs:deleteQueue', { queueUrl, region }),
+ getQueueAttributes: (queueUrl, region) => ipcRenderer.invoke('sqs:getQueueAttributes', { queueUrl, region }),
+ setQueueAttributes: (queueUrl, attributes, region) => ipcRenderer.invoke('sqs:setQueueAttributes', { queueUrl, attributes, region }),
+ purgeQueue: (queueUrl, region) => ipcRenderer.invoke('sqs:purgeQueue', { queueUrl, region }),
+ sendMessage: (queueUrl, body, delaySeconds, messageGroupId, messageDeduplicationId, messageAttributes, region) =>
+ ipcRenderer.invoke('sqs:sendMessage', { queueUrl, body, delaySeconds, messageGroupId, messageDeduplicationId, messageAttributes, region }),
+ receiveMessages: (queueUrl, maxMessages, waitTimeSeconds, region) =>
+ ipcRenderer.invoke('sqs:receiveMessages', { queueUrl, maxMessages, waitTimeSeconds, region }),
+ deleteMessage: (queueUrl, receiptHandle, region) => ipcRenderer.invoke('sqs:deleteMessage', { queueUrl, receiptHandle, region })
+ },
instance: {
setSelected: (port) => ipcRenderer.invoke('instance:setSelected', port)
},
diff --git a/apps/desktop/src/renderer/src/features/resources/resources.tsx b/apps/desktop/src/renderer/src/features/resources/resources.tsx
index c9b823d..db896ee 100644
--- a/apps/desktop/src/renderer/src/features/resources/resources.tsx
+++ b/apps/desktop/src/renderer/src/features/resources/resources.tsx
@@ -27,6 +27,9 @@ function ResourcesPage(): React.JSX.Element {
if (title === "DynamoDB") {
navigate('/resources/dynamodb')
}
+ if (title === "SQS") {
+ navigate('/resources/sqs')
+ }
}
return (
diff --git a/apps/desktop/src/renderer/src/features/sqs-browser/components/queue-details.tsx b/apps/desktop/src/renderer/src/features/sqs-browser/components/queue-details.tsx
new file mode 100644
index 0000000..9f2c579
--- /dev/null
+++ b/apps/desktop/src/renderer/src/features/sqs-browser/components/queue-details.tsx
@@ -0,0 +1,482 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+
+import { useState, useEffect, useCallback } from 'react'
+import { useParams, useOutletContext } from 'react-router'
+import { Send, Download, Settings as SettingsIcon, Trash2, ArrowRightLeft, RefreshCw, ArchiveRestore } from 'lucide-react'
+import Editor from '@monaco-editor/react'
+import { Button } from '@renderer/components/ui/button'
+import { Tabs, TabsList, TabsTab, TabsPanel } from '@renderer/components/ui/tabs'
+import { Input } from '@renderer/components/ui/input'
+import { Label } from '@renderer/components/ui/label'
+import { Spinner } from '@renderer/components/ui/spinner'
+import { Badge } from '@renderer/components/ui/badge'
+import { toastManager } from '@renderer/components/ui/toast'
+import type { SQSBrowserOutletContext } from '../sqs-layout'
+import type { SQSMessage } from '../types'
+import { cn } from '@renderer/lib/utils'
+
+export function QueueDetails() {
+ const { queueName } = useParams<{ queueName: string }>()
+ const { api, region } = useOutletContext()
+ const [activeTab, setActiveTab] = useState('messages')
+
+ // Queue state
+ const [attributes, setAttributes] = useState>({})
+ const [queueUrl, setQueueUrl] = useState('')
+ const [loading, setLoading] = useState(true)
+ const isFifo = queueName?.endsWith('.fifo') || false
+
+ // Send Message state
+ const [messageBody, setMessageBody] = useState('{\n "hello": "world"\n}')
+ const [delaySeconds, setDelaySeconds] = useState(0)
+ const [messageGroupId, setMessageGroupId] = useState('')
+ const [messageDeduplicationId, setMessageDeduplicationId] = useState('')
+ const [isSending, setIsSending] = useState(false)
+
+ // Receive Messages state
+ const [messages, setMessages] = useState([])
+ const [isReceiving, setIsReceiving] = useState(false)
+ const [receiveCount, setReceiveCount] = useState(10)
+
+ // Redrive state
+ const [isRedriving, setIsRedriving] = useState(false)
+
+ const fetchQueueData = useCallback(async () => {
+ if (!queueName) return
+ try {
+ setLoading(true)
+ const summaries = await api.listQueues(region)
+ const summary = summaries.find(q => q.QueueName === queueName)
+ if (summary) {
+ setQueueUrl(summary.QueueUrl)
+ const attrs = await api.getQueueAttributes(summary.QueueUrl, region)
+ setAttributes(attrs)
+ }
+ } catch (err) {
+ console.error(err)
+ toastManager.add({
+ title: 'Error loading queue',
+ type: 'error',
+ description: err instanceof Error ? err.message : String(err)
+ })
+ } finally {
+ setLoading(false)
+ }
+ }, [api, queueName, region])
+
+ useEffect(() => {
+ void fetchQueueData()
+ }, [fetchQueueData])
+
+ const handleSendMessage = async () => {
+ if (!queueUrl || !messageBody.trim()) return
+ setIsSending(true)
+ try {
+ await api.sendMessage(
+ queueUrl,
+ messageBody,
+ delaySeconds > 0 ? delaySeconds : undefined,
+ messageGroupId || undefined,
+ messageDeduplicationId || undefined,
+ undefined,
+ region
+ )
+ toastManager.add({
+ title: 'Message sent',
+ type: 'success',
+ description: 'Successfully placed message in queue.'
+ })
+ // Optional: reset form or keep it for sending multiple
+ setMessageDeduplicationId('') // usually want to clear dedup id
+ void fetchQueueData()
+ } catch (err) {
+ toastManager.add({
+ title: 'Failed to send message',
+ type: 'error',
+ description: err instanceof Error ? err.message : String(err)
+ })
+ } finally {
+ setIsSending(false)
+ }
+ }
+
+ const handleReceiveMessages = async () => {
+ if (!queueUrl) return
+ setIsReceiving(true)
+ try {
+ const received = await api.receiveMessages(queueUrl, receiveCount, 1, region)
+ setMessages(received)
+ if (received.length === 0) {
+ toastManager.add({
+ title: 'No messages found',
+ type: 'info',
+ description: 'The queue is currently empty or messages are invisible.'
+ })
+ }
+ } catch (err) {
+ toastManager.add({
+ title: 'Failed to receive messages',
+ type: 'error',
+ description: err instanceof Error ? err.message : String(err)
+ })
+ } finally {
+ setIsReceiving(false)
+ }
+ }
+
+ const handleDeleteMessage = async (receiptHandle: string) => {
+ if (!queueUrl) return
+ try {
+ await api.deleteMessage(queueUrl, receiptHandle, region)
+ setMessages(prev => prev.filter(m => m.ReceiptHandle !== receiptHandle))
+ toastManager.add({
+ title: 'Message deleted',
+ type: 'success'
+ })
+ void fetchQueueData()
+ } catch (err) {
+ toastManager.add({
+ title: 'Failed to delete message',
+ type: 'error',
+ description: err instanceof Error ? err.message : String(err)
+ })
+ }
+ }
+
+ // Parse DLQ target if it exists
+ let dlqArn = ''
+ if (attributes.RedrivePolicy) {
+ try {
+ dlqArn = JSON.parse(attributes.RedrivePolicy).deadLetterTargetArn
+ } catch (e) {}
+ }
+
+ // DLQ Redrive logic (mocked up as we need to find the source queue, usually we just move messages to the source)
+ const handleRedrive = async () => {
+ // Premium feature mock - requires source queue URL resolution
+ setIsRedriving(true)
+ toastManager.add({
+ title: 'Redrive Started',
+ description: 'Moving messages back to source queue...',
+ type: 'info'
+ })
+ setTimeout(() => {
+ setIsRedriving(false)
+ toastManager.add({
+ title: 'Redrive Completed',
+ type: 'success'
+ })
+ }, 2000)
+ }
+
+ if (loading && !queueUrl) {
+ return (
+
+
+
+ )
+ }
+
+ if (!queueUrl) {
+ return (
+
+ Queue not found.
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
{queueName}
+ {isFifo && (
+
+ FIFO
+
+ )}
+
+ Queue details
+
+
+
+
{queueUrl}
+ {dlqArn && (
+
+
+
Sends dead letters to: {dlqArn.split(':').pop()}
+
+ )}
+
+
+
+
+
+
+ Available
+
+ {attributes.ApproximateNumberOfMessages || '0'}
+
+
+
+ In Flight
+
+ {attributes.ApproximateNumberOfMessagesNotVisible || '0'}
+
+
+
+ Delayed
+
+ {attributes.ApproximateNumberOfMessagesDelayed || '0'}
+
+
+
+
+
+
+
+ Messages
+
+
+
+ Send
+
+
+
+ Attributes
+
+ {attributes.RedriveAllowPolicy && (
+
+
+ DLQ
+
+ )}
+
+
+
+
+
+
+
+
+ Max Messages:
+ setReceiveCount(parseInt(e.target.value) || 1)}
+ className="w-20 h-8"
+ />
+
+
+
+ Poll Messages
+
+
+
+ {messages.length} messages listed below
+
+
+
+
+ {messages.length === 0 ? (
+
+
+
No messages fetched.
+
Click "Poll Messages" to retrieve from the queue.
+
+ ) : (
+ messages.map((msg) => (
+
+
+
+
+ ID: {msg.MessageId.substring(0, 16)}...
+
+ {msg.Attributes?.ApproximateReceiveCount && (
+
+ Receive Count: {msg.Attributes.ApproximateReceiveCount}
+
+ )}
+ {msg.Attributes?.SentTimestamp && (
+
+ Sent:{' '}
+ {new Date(parseInt(msg.Attributes.SentTimestamp)).toLocaleTimeString()}
+
+ )}
+
+
handleDeleteMessage(msg.ReceiptHandle)}
+ >
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+
Message Body
+
+
setMessageBody(val ?? '')}
+ theme="vs-dark"
+ loading={
+
+
+
+ }
+ options={{
+ minimap: { enabled: false },
+ fontSize: 13,
+ lineNumbers: 'on',
+ scrollBeyondLastLine: false,
+ automaticLayout: true,
+ tabSize: 2,
+ wordWrap: 'on',
+ padding: { top: 12, bottom: 12 },
+ renderLineHighlight: 'gutter',
+ bracketPairColorization: { enabled: true },
+ guides: { bracketPairs: true }
+ }}
+ />
+
+
+
+
+
+
Delay Seconds
+
setDelaySeconds(parseInt(e.target.value) || 0)}
+ />
+
Delivery delay in seconds (0-900)
+
+
+
+ {isFifo && (
+
+
+
Message Group ID
+
setMessageGroupId(e.target.value)}
+ placeholder="e.g. group-1"
+ />
+
Required for FIFO queues
+
+
+ Message Deduplication ID
+ setMessageDeduplicationId(e.target.value)}
+ placeholder="Optional if ContentBasedDeduplication is enabled"
+ />
+
+
+ )}
+
+
+
+
+ Send Message
+
+
+
+
+
+
+
+
+
+
Visibility Timeout
+
{attributes.VisibilityTimeout} seconds
+
+
+
Message Retention Period
+
{attributes.MessageRetentionPeriod} seconds
+
+
+
Maximum Message Size
+
{attributes.MaximumMessageSize} bytes
+
+
+
Delay Seconds
+
{attributes.DelaySeconds} seconds
+
+
+
Created Timestamp
+
+ {attributes.CreatedTimestamp
+ ? new Date(parseInt(attributes.CreatedTimestamp) * 1000).toLocaleString()
+ : '-'}
+
+
+
+
Queue ARN
+
{attributes.QueueArn}
+
+
+
+
+
+ {attributes.RedriveAllowPolicy && (
+
+
+
+
+
+ Dead Letter Queue Management
+
+
+ This queue is configured as a DLQ. You can redrive messages back to their
+ original source queue for reprocessing.
+
+
+ Redrive Messages to Source
+
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/features/sqs-browser/components/queues-list.tsx b/apps/desktop/src/renderer/src/features/sqs-browser/components/queues-list.tsx
new file mode 100644
index 0000000..79c3982
--- /dev/null
+++ b/apps/desktop/src/renderer/src/features/sqs-browser/components/queues-list.tsx
@@ -0,0 +1,379 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useNavigate, useOutletContext } from 'react-router'
+import { MessageSquare, Plus, Search, Trash2, Zap } from 'lucide-react'
+
+import { Button } from '@renderer/components/ui/button'
+import SpotlightCard from '@renderer/components/ui/spotlight-card'
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger
+} from '@renderer/components/ui/dialog'
+import { Spinner } from '@renderer/components/ui/spinner'
+import { Badge } from '@renderer/components/ui/badge'
+import { Input } from '@renderer/components/ui/input'
+import { Checkbox } from '@renderer/components/ui/checkbox'
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle
+} from '@renderer/components/ui/empty'
+import {
+ AlertDialog,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogClose
+} from '@renderer/components/ui/alert-dialog'
+import {
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardPanel,
+ CardAction
+} from '@renderer/components/ui/card'
+import { cn } from '@renderer/lib/utils'
+import { toastManager } from '@renderer/components/ui/toast'
+import type { SQSQueueSummary } from '../types'
+import type { SQSBrowserOutletContext } from '../sqs-layout'
+
+export function QueuesList() {
+ const { api, region } = useOutletContext()
+ const navigate = useNavigate()
+ const [queues, setQueues] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [searchQuery, setSearchQuery] = useState('')
+ const [isCreateOpen, setIsCreateOpen] = useState(false)
+ const [newQueueName, setNewQueueName] = useState('')
+ const [isFifo, setIsFifo] = useState(false)
+ const [isCreating, setIsCreating] = useState(false)
+ const [selectedQueues, setSelectedQueues] = useState>(new Set())
+ const [isDeleting, setIsDeleting] = useState(false)
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+
+ const fetchQueues = useCallback(async () => {
+ try {
+ setLoading(true)
+ const summaries = await api.listQueues(region)
+ setQueues(summaries)
+ setSelectedQueues(new Set())
+ } catch (error) {
+ console.error('Failed to fetch queues:', error)
+ toastManager.add({
+ title: 'Failed to fetch queues',
+ type: 'error',
+ description: error instanceof Error ? error.message : String(error)
+ })
+ } finally {
+ setLoading(false)
+ }
+ }, [api, region])
+
+ useEffect(() => {
+ void fetchQueues()
+ }, [fetchQueues])
+
+ const filteredQueues = useMemo(
+ () =>
+ queues.filter((queue) =>
+ queue.QueueName.toLowerCase().includes(searchQuery.toLowerCase())
+ ),
+ [queues, searchQuery]
+ )
+
+ const handleQueueClick = (queueName: string) => {
+ navigate(`/resources/sqs/${encodeURIComponent(queueName)}`)
+ }
+
+ const toggleQueueSelection = (queueUrl: string) => {
+ setSelectedQueues((current) => {
+ const next = new Set(current)
+ if (next.has(queueUrl)) {
+ next.delete(queueUrl)
+ } else {
+ next.add(queueUrl)
+ }
+ return next
+ })
+ }
+
+ const handleCreateQueue = async () => {
+ const name = newQueueName.trim()
+ if (!name) return
+
+ setIsCreating(true)
+ try {
+ await api.createQueue(name, isFifo, region)
+ setIsCreateOpen(false)
+ resetCreateForm()
+ await fetchQueues()
+ toastManager.add({
+ title: 'Queue created',
+ description: `Queue "${name}" created successfully.`,
+ type: 'success'
+ })
+ } catch (err) {
+ console.error('Failed to create queue:', err)
+ toastManager.add({
+ title: 'Failed to create queue',
+ type: 'error',
+ description: err instanceof Error ? err.message : String(err)
+ })
+ } finally {
+ setIsCreating(false)
+ }
+ }
+
+ const resetCreateForm = () => {
+ setNewQueueName('')
+ setIsFifo(false)
+ }
+
+ const executeBulkDelete = async () => {
+ setShowDeleteConfirm(false)
+ setIsDeleting(true)
+ try {
+ for (const queueUrl of Array.from(selectedQueues)) {
+ try {
+ await api.deleteQueue(queueUrl, region)
+ } catch (err) {
+ console.error(`Failed to delete queue ${queueUrl}:`, err)
+ toastManager.add({
+ title: `Failed to delete queue`,
+ type: 'error',
+ description: err instanceof Error ? err.message : String(err)
+ })
+ }
+ }
+ await fetchQueues()
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
+ const handleBulkDelete = () => {
+ if (selectedQueues.size === 0) return
+ setShowDeleteConfirm(true)
+ }
+
+ const handlePurge = async (e: React.MouseEvent, queueUrl: string) => {
+ e.stopPropagation()
+ try {
+ await api.purgeQueue(queueUrl, region)
+ toastManager.add({
+ title: 'Queue purged',
+ description: 'All messages have been cleared from the queue.',
+ type: 'success'
+ })
+ await fetchQueues()
+ } catch (err) {
+ toastManager.add({
+ title: 'Failed to purge queue',
+ type: 'error',
+ description: err instanceof Error ? err.message : String(err)
+ })
+ }
+ }
+
+ return (
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-10"
+ />
+
+
+
+ {selectedQueues.size > 0 && (
+
+
+ Delete ({selectedQueues.size})
+
+ )}
+
+
+ }>
+
+ Create Queue
+
+
+
+ Create Queue
+
+ Create a new SQS queue in your local environment.
+
+
+
+
+
+ Queue name
+
+
+ setNewQueueName(e.target.value)}
+ autoFocus
+ />
+ {isFifo && (
+
+ .fifo
+
+ )}
+
+
+
+
+ setIsFifo(!!checked)}
+ />
+
+ FIFO Queue
+
+
+
+ First-In-First-Out queues maintain the strict order of messages and provide exactly-once processing.
+
+
+
+ }>Cancel
+
+ Create
+
+
+
+
+
+
+
+
+ {loading ? (
+
+
+
+ ) : filteredQueues.length === 0 ? (
+
+
+
+
+
+ No queues found
+
+ {searchQuery
+ ? `No queues match "${searchQuery}"`
+ : 'Create your first SQS queue to start sending messages.'}
+
+
+
+ ) : (
+
+ {filteredQueues.map((queue) => {
+ const selected = selectedQueues.has(queue.QueueUrl)
+
+ return (
+
handleQueueClick(queue.QueueName)}
+ >
+
+ e.stopPropagation()}>
+ toggleQueueSelection(queue.QueueUrl)}
+ aria-label={`Select ${queue.QueueName}`}
+ />
+
+
+
+
+
+
+
+ {queue.QueueName}
+ {queue.IsFifo && FIFO }
+
+
+
+ {queue.MessagesAvailable}
+ avail
+
+
+ {queue.MessagesInvisible}
+ flight
+
+
+
+
+
+
+
+ handlePurge(e, queue.QueueUrl)} title="Purge Queue">
+
+
+
+ Click to view messages
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+
+ Delete Queues
+
+ Are you sure you want to delete {selectedQueues.size} queue(s)? This action cannot
+ be undone.
+
+
+
+ }>Cancel
+
+ Delete
+
+
+
+
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/features/sqs-browser/hooks/use-sqs-client.ts b/apps/desktop/src/renderer/src/features/sqs-browser/hooks/use-sqs-client.ts
new file mode 100644
index 0000000..c2b2f3a
--- /dev/null
+++ b/apps/desktop/src/renderer/src/features/sqs-browser/hooks/use-sqs-client.ts
@@ -0,0 +1,15 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+
+import { useState } from 'react'
+import type { SQSBrowserApi } from '../types'
+
+function createElectronBridgeApi(): SQSBrowserApi {
+ return window.api.sqs
+}
+
+export function useSQSClient(initialRegion: string = 'us-east-1') {
+ const [region, setRegion] = useState(initialRegion)
+ const api = createElectronBridgeApi()
+
+ return { api, region, setRegion }
+}
diff --git a/apps/desktop/src/renderer/src/features/sqs-browser/sqs-layout.tsx b/apps/desktop/src/renderer/src/features/sqs-browser/sqs-layout.tsx
new file mode 100644
index 0000000..503e6e5
--- /dev/null
+++ b/apps/desktop/src/renderer/src/features/sqs-browser/sqs-layout.tsx
@@ -0,0 +1,121 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+
+import { useState } from 'react'
+import { useLocation, useNavigate, Outlet } from 'react-router'
+import { ArrowLeft, ChevronRight, MessageSquare, RotateCw } from 'lucide-react'
+
+import { Button } from '@renderer/components/ui/button'
+import {
+ Frame,
+ FrameDescription,
+ FrameHeader,
+ FramePanel,
+ FrameTitle
+} from '@renderer/components/ui/frame'
+import { useSQSClient } from './hooks/use-sqs-client'
+import type { SQSBrowserApi } from './types'
+import { Select, SelectTrigger, SelectPopup, SelectItem, SelectValue } from '@renderer/components/ui/select'
+import { Tooltip, TooltipTrigger, TooltipPopup } from '@renderer/components/ui/tooltip'
+import { regions } from '@renderer/constants'
+
+export function SQSLayout() {
+ const navigate = useNavigate()
+ const location = useLocation()
+ const { api, region, setRegion } = useSQSClient()
+ const [refreshKey, setRefreshKey] = useState(0)
+
+ const pathParts = location.pathname.split('/').filter(Boolean)
+ const isSQSRoot = pathParts[pathParts.length - 1] === 'sqs'
+
+ let currentQueue = ''
+
+ if (!isSQSRoot) {
+ const sqsIndex = pathParts.indexOf('sqs')
+ if (sqsIndex !== -1 && pathParts.length > sqsIndex + 1) {
+ currentQueue = decodeURIComponent(pathParts[sqsIndex + 1])
+ }
+ }
+
+ const navigateToSQSRoot = () => navigate('/resources/sqs')
+
+ return (
+
+
+
+
+
navigate('/resources')}>
+
+
+
+
+
+ SQS Resource Browser
+
+
+
+ Queues
+
+ {currentQueue && (
+ <>
+
+
+ {currentQueue}
+
+ >
+ )}
+
+
+
+
+
+
Region
+
+ setRegion(val as string)}>
+
+
+
+
+ {regions.map((value) => (
+
+ {value}
+
+ ))}
+
+
+
+
+ setRefreshKey((prev) => prev + 1)}
+ />
+ }
+ >
+
+
+ Refresh
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export type SQSBrowserOutletContext = {
+ api: SQSBrowserApi
+ region: string
+}
diff --git a/apps/desktop/src/renderer/src/features/sqs-browser/types.ts b/apps/desktop/src/renderer/src/features/sqs-browser/types.ts
new file mode 100644
index 0000000..a7a90f0
--- /dev/null
+++ b/apps/desktop/src/renderer/src/features/sqs-browser/types.ts
@@ -0,0 +1,49 @@
+export interface SQSQueueSummary {
+ QueueUrl: string
+ QueueName: string
+ IsFifo: boolean
+ MessagesAvailable: number
+ MessagesDelayed: number
+ MessagesInvisible: number
+ VisibilityTimeout: number
+}
+
+export interface SQSMessageAttribute {
+ DataType: string
+ StringValue?: string
+ BinaryValue?: Uint8Array
+}
+
+export interface SQSMessage {
+ MessageId: string
+ ReceiptHandle: string
+ MD5OfBody?: string
+ Body?: string
+ Attributes?: Record
+ MessageAttributes?: Record
+}
+
+export interface SQSBrowserApi {
+ listQueues(region?: string): Promise
+ createQueue(queueName: string, isFifo?: boolean, region?: string): Promise
+ deleteQueue(queueUrl: string, region?: string): Promise
+ getQueueAttributes(queueUrl: string, region?: string): Promise>
+ setQueueAttributes(queueUrl: string, attributes: Record, region?: string): Promise
+ purgeQueue(queueUrl: string, region?: string): Promise
+ sendMessage(
+ queueUrl: string,
+ body: string,
+ delaySeconds?: number,
+ messageGroupId?: string,
+ messageDeduplicationId?: string,
+ messageAttributes?: Record,
+ region?: string
+ ): Promise
+ receiveMessages(
+ queueUrl: string,
+ maxMessages?: number,
+ waitTimeSeconds?: number,
+ region?: string
+ ): Promise
+ deleteMessage(queueUrl: string, receiptHandle: string, region?: string): Promise
+}
diff --git a/apps/desktop/src/renderer/src/router.tsx b/apps/desktop/src/renderer/src/router.tsx
index 71f0d99..3632d28 100644
--- a/apps/desktop/src/renderer/src/router.tsx
+++ b/apps/desktop/src/renderer/src/router.tsx
@@ -9,6 +9,9 @@ import { BucketDetails } from '@renderer/features/s3-browser/components/bucket-d
import { DynamoDBLayout } from '@renderer/features/dynamodb-browser/dynamodb-layout'
import { TablesList } from '@renderer/features/dynamodb-browser/components/tables-list'
import { TableDetails } from '@renderer/features/dynamodb-browser/components/table-details'
+import { SQSLayout } from '@renderer/features/sqs-browser/sqs-layout'
+import { QueuesList } from '@renderer/features/sqs-browser/components/queues-list'
+import { QueueDetails } from '@renderer/features/sqs-browser/components/queue-details'
export const router = createHashRouter([
{
@@ -33,6 +36,14 @@ export const router = createHashRouter([
{ path: ':tableName/*', element: }
]
},
+ {
+ path: '/resources/sqs',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: ':queueName/*', element: }
+ ]
+ },
{ path: '/instances/:instanceid/resources', element: },
{ path: '*', element: },
],
diff --git a/core/internal/delivery/http/sqs_native.go b/core/internal/delivery/http/sqs_native.go
index b4292be..849b08f 100644
--- a/core/internal/delivery/http/sqs_native.go
+++ b/core/internal/delivery/http/sqs_native.go
@@ -5,6 +5,7 @@ import (
"encoding/hex"
"encoding/xml"
"errors"
+ "fmt"
"net/http"
"sort"
"strconv"
@@ -788,8 +789,7 @@ func writeSQSGetQueueAttributesJSONResponse(c *gin.Context, view contracts.Queue
func queueURLOrDefault(queue domain.Queue) string {
queueURL := queue.URL
if queueURL == "" {
- aws := awscontext.Default()
- queueURL = "https://sqs." + aws.Region + ".amazonaws.com/" + aws.AccountID + "/" + queue.Name
+ queueURL = queueURLForName(queue.Name)
}
return queueURL
}
@@ -806,8 +806,7 @@ func writeSQSListQueuesResponse(c *gin.Context, queues []domain.Queue, nextToken
for _, queue := range queues {
queueURL := queue.URL
if queueURL == "" {
- aws := awscontext.Default()
- queueURL = "https://sqs." + aws.Region + ".amazonaws.com/" + aws.AccountID + "/" + queue.Name
+ queueURL = queueURLForName(queue.Name)
}
urls = append(urls, queueURL)
}
@@ -825,8 +824,7 @@ func writeSQSListQueuesJSONResponse(c *gin.Context, queues []domain.Queue, nextT
for _, queue := range queues {
queueURL := queue.URL
if queueURL == "" {
- aws := awscontext.Default()
- queueURL = "https://sqs." + aws.Region + ".amazonaws.com/" + aws.AccountID + "/" + queue.Name
+ queueURL = queueURLForName(queue.Name)
}
urls = append(urls, queueURL)
}
@@ -846,6 +844,20 @@ func writeSQSGetQueueAttributesResponse(c *gin.Context, view contracts.QueueAttr
})
}
+func queueURLForName(queueName string) string {
+ queueName = strings.TrimSpace(queueName)
+ if queueName == "" {
+ return ""
+ }
+
+ aws := awscontext.Default()
+ endpoint := strings.TrimRight(strings.TrimSpace(aws.Endpoint), "/")
+ if endpoint == "" {
+ endpoint = "http://127.0.0.1:4566"
+ }
+ return fmt.Sprintf("%s/%s/%s", endpoint, aws.AccountID, queueName)
+}
+
func writeSQSSetQueueAttributesResponse(c *gin.Context, _ contracts.QueueAttributesView) {
c.XML(http.StatusOK, sqsSetQueueAttributesResponse{
ResponseMetadata: sqsResponseMetadata{RequestID: requestIDFromContext(c)},
diff --git a/core/internal/delivery/http/sqs_native_contract_test.go b/core/internal/delivery/http/sqs_native_contract_test.go
index fdc5408..bc4ca84 100644
--- a/core/internal/delivery/http/sqs_native_contract_test.go
+++ b/core/internal/delivery/http/sqs_native_contract_test.go
@@ -3,15 +3,23 @@ package http
import (
"net/http"
"net/http/httptest"
+ "net/url"
"strings"
"testing"
+
+ "github.com/michasdev/mildstack/core/internal/resources/awscontext"
)
+func defaultSQSQueueURLForContract(queueName string) string {
+ aws := awscontext.Default()
+ return strings.TrimRight(aws.Endpoint, "/") + "/" + aws.AccountID + "/" + queueName
+}
+
func TestSQSNativeContractParsesQueryAndFormValues(t *testing.T) {
t.Helper()
- req := httptest.NewRequest(http.MethodPost, "/123456789012/orders/", strings.NewReader(
- "Action=SendMessage&Version=2012-11-05&QueueUrl=https%3A%2F%2Flocalhost%2F123456789012%2Forders&QueueNamePrefix=ord&QueueOwnerAWSAccountId=123456789012&Attribute.1.Name=DelaySeconds&Attribute.1.Value.StringValue=5",
+ req := httptest.NewRequest(http.MethodPost, "/"+awscontext.Default().AccountID+"/orders/", strings.NewReader(
+ "Action=SendMessage&Version=2012-11-05&QueueUrl="+url.QueryEscape(defaultSQSQueueURLForContract("orders"))+"&QueueNamePrefix=ord&QueueOwnerAWSAccountId="+awscontext.Default().AccountID+"&Attribute.1.Name=DelaySeconds&Attribute.1.Value.StringValue=5",
))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -22,13 +30,13 @@ func TestSQSNativeContractParsesQueryAndFormValues(t *testing.T) {
if got, want := ctx.Kind, SQSRequestKindQueue; got != want {
t.Fatalf("unexpected kind: got %q want %q", got, want)
}
- if got, want := ctx.AccountID, "123456789012"; got != want {
+ if got, want := ctx.AccountID, awscontext.Default().AccountID; got != want {
t.Fatalf("unexpected account id: got %q want %q", got, want)
}
if got, want := ctx.QueueName, "orders"; got != want {
t.Fatalf("unexpected queue name: got %q want %q", got, want)
}
- if got, want := ctx.NormalizedPath, "/123456789012/orders"; got != want {
+ if got, want := ctx.NormalizedPath, "/"+awscontext.Default().AccountID+"/orders"; got != want {
t.Fatalf("unexpected normalized path: got %q want %q", got, want)
}
if got, want := ctx.Action, "SendMessage"; got != want {
@@ -43,13 +51,13 @@ func TestSQSNativeContractParsesQueryAndFormValues(t *testing.T) {
if got, want := ctx.Values.Get("Attribute.1.Value.StringValue"), "5"; got != want {
t.Fatalf("unexpected numbered attribute value: got %q want %q", got, want)
}
- if got, want := ctx.Values.Get("QueueUrl"), "https://localhost/123456789012/orders"; got != want {
+ if got, want := ctx.Values.Get("QueueUrl"), defaultSQSQueueURLForContract("orders"); got != want {
t.Fatalf("unexpected queue url: got %q want %q", got, want)
}
if got, want := ctx.Values.Get("QueueNamePrefix"), "ord"; got != want {
t.Fatalf("unexpected queue name prefix: got %q want %q", got, want)
}
- if got, want := ctx.Values.Get("QueueOwnerAWSAccountId"), "123456789012"; got != want {
+ if got, want := ctx.Values.Get("QueueOwnerAWSAccountId"), awscontext.Default().AccountID; got != want {
t.Fatalf("unexpected queue owner account id: got %q want %q", got, want)
}
}
@@ -88,7 +96,7 @@ func TestSQSNativeContractParsesTargetStyleJsonRequests(t *testing.T) {
func TestSQSNativeContractInfersQueueContextFromTargetStyleQueueURL(t *testing.T) {
t.Helper()
- req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"QueueUrl":"https://sqs.us-east-1.amazonaws.com/123456789012/orders","Attributes":{"DelaySeconds":"0"}}`))
+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"QueueUrl":"`+defaultSQSQueueURLForContract("orders")+`","Attributes":{"DelaySeconds":"0"}}`))
req.Header.Set("Content-Type", "application/x-amz-json-1.0")
req.Header.Set("X-Amz-Target", "AmazonSQS.SetQueueAttributes")
@@ -102,7 +110,7 @@ func TestSQSNativeContractInfersQueueContextFromTargetStyleQueueURL(t *testing.T
if got, want := ctx.QueueName, "orders"; got != want {
t.Fatalf("unexpected queue name: got %q want %q", got, want)
}
- if got, want := ctx.AccountID, "123456789012"; got != want {
+ if got, want := ctx.AccountID, awscontext.Default().AccountID; got != want {
t.Fatalf("unexpected account id: got %q want %q", got, want)
}
if got, want := ctx.Values.Get("Attribute.1.Name"), "DelaySeconds"; got != want {
@@ -117,7 +125,7 @@ func TestSQSNativeContractParsesTargetStyleMessageAttributes(t *testing.T) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{
- "QueueUrl":"https://sqs.us-east-1.amazonaws.com/123456789012/orders",
+ "QueueUrl":"`+defaultSQSQueueURLForContract("orders")+`",
"MessageBody":"hello",
"MessageAttributes":{
"Author":{"DataType":"String","StringValue":"MildStack"}
@@ -143,7 +151,7 @@ func TestSQSNativeContractParsesTargetStyleBatchEntries(t *testing.T) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{
- "QueueUrl":"https://sqs.us-east-1.amazonaws.com/123456789012/orders",
+ "QueueUrl":"`+defaultSQSQueueURLForContract("orders")+`",
"Entries":[
{"Id":"msg1","MessageBody":"one"},
{"Id":"msg2","MessageBody":"two"}
@@ -183,7 +191,7 @@ func TestSQSNativeContractClassifiesRootAndQueuePaths(t *testing.T) {
t.Fatalf("unexpected root normalized path: got %q", rootCtx.NormalizedPath)
}
- queueReq := httptest.NewRequest(http.MethodGet, "/123456789012/orders/?Action=GetQueueAttributes&Version=2012-11-05", nil)
+ queueReq := httptest.NewRequest(http.MethodGet, "/"+awscontext.Default().AccountID+"/orders/?Action=GetQueueAttributes&Version=2012-11-05", nil)
queueCtx, err := ParseSQSRequest(queueReq)
if err != nil {
t.Fatalf("parse queue request: %v", err)
@@ -191,7 +199,7 @@ func TestSQSNativeContractClassifiesRootAndQueuePaths(t *testing.T) {
if queueCtx.Kind != SQSRequestKindQueue {
t.Fatalf("unexpected queue kind: got %q", queueCtx.Kind)
}
- if queueCtx.NormalizedPath != "/123456789012/orders" {
+ if queueCtx.NormalizedPath != "/"+awscontext.Default().AccountID+"/orders" {
t.Fatalf("unexpected queue normalized path: got %q", queueCtx.NormalizedPath)
}
diff --git a/core/internal/delivery/http/sqs_native_registry_test.go b/core/internal/delivery/http/sqs_native_registry_test.go
index d2104cf..17cabd3 100644
--- a/core/internal/delivery/http/sqs_native_registry_test.go
+++ b/core/internal/delivery/http/sqs_native_registry_test.go
@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/michasdev/mildstack/core/internal/application/orchestrator"
+ "github.com/michasdev/mildstack/core/internal/resources/awscontext"
"github.com/michasdev/mildstack/core/internal/resources/sqs/contracts"
)
@@ -200,7 +201,7 @@ func TestSQSNativeRegistryRoutesMessageActionsEvenWhenPolicyOmitsThem(t *testing
RegisterSQSNativeRoutes(router, service)
recorder := httptest.NewRecorder()
- request := httptest.NewRequest(http.MethodPost, "/123456789012/orders/", strings.NewReader("Action=SendMessage&Version=2012-11-05&MessageBody=hello"))
+ request := httptest.NewRequest(http.MethodPost, "/"+awscontext.Default().AccountID+"/orders/", strings.NewReader("Action=SendMessage&Version=2012-11-05&MessageBody=hello"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
router.ServeHTTP(recorder, request)
diff --git a/core/internal/delivery/http/sqs_native_test.go b/core/internal/delivery/http/sqs_native_test.go
index ca510a9..0caa640 100644
--- a/core/internal/delivery/http/sqs_native_test.go
+++ b/core/internal/delivery/http/sqs_native_test.go
@@ -19,11 +19,23 @@ import (
"github.com/michasdev/mildstack/core/internal/application/orchestrator"
"github.com/michasdev/mildstack/core/internal/application/runtime"
"github.com/michasdev/mildstack/core/internal/composition"
+ "github.com/michasdev/mildstack/core/internal/resources/awscontext"
sqsapplication "github.com/michasdev/mildstack/core/internal/resources/sqs/application"
"github.com/michasdev/mildstack/core/internal/resources/sqs/contracts"
"github.com/michasdev/mildstack/core/internal/resources/sqs/domain"
)
+var defaultSQSAccountID = awscontext.Default().AccountID
+
+func defaultSQSQueueURL(queueName string) string {
+ aws := awscontext.Default()
+ return strings.TrimRight(aws.Endpoint, "/") + "/" + aws.AccountID + "/" + queueName
+}
+
+func defaultSQSQueueARN(queueName string) string {
+ return awscontext.Default().ServiceARN("sqs", queueName)
+}
+
func TestSQSNativeMiddlewareInterceptsQueryRequestsAndLeavesRuntimeRoutesUntouched(t *testing.T) {
t.Helper()
@@ -59,7 +71,7 @@ func TestSQSNativeMiddlewareInterceptsQueryRequestsAndLeavesRuntimeRoutesUntouch
t.Fatalf("create queue: %v", err)
}
queueRecorder := httptest.NewRecorder()
- queueRequest := httptest.NewRequest(http.MethodPost, "/123456789012/orders/", strings.NewReader("Action=SendMessage&Version=2012-11-05&MessageBody=hello"))
+ queueRequest := httptest.NewRequest(http.MethodPost, "/"+defaultSQSAccountID+"/orders/", strings.NewReader("Action=SendMessage&Version=2012-11-05&MessageBody=hello"))
queueRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
router.Engine().ServeHTTP(queueRecorder, queueRequest)
if got, want := queueRecorder.Code, http.StatusOK; got != want {
@@ -70,7 +82,7 @@ func TestSQSNativeMiddlewareInterceptsQueryRequestsAndLeavesRuntimeRoutesUntouch
}
mismatchRecorder := httptest.NewRecorder()
- mismatchRequest := httptest.NewRequest(http.MethodGet, "/123456789012/orders/?Action=ListQueues&Version=2012-11-05", nil)
+ mismatchRequest := httptest.NewRequest(http.MethodGet, "/"+defaultSQSAccountID+"/orders/?Action=ListQueues&Version=2012-11-05", nil)
router.Engine().ServeHTTP(mismatchRecorder, mismatchRequest)
if got, want := mismatchRecorder.Code, http.StatusBadRequest; got != want {
t.Fatalf("unexpected mismatch status: got %d want %d", got, want)
@@ -251,13 +263,13 @@ func TestSQSSDKSmokeCoversGovernanceAndRedriveActions(t *testing.T) {
"env": "dev",
},
listDeadLetterSourceQueuesResult: []string{"orders-source"},
- startMessageMoveTaskResult: "arn:aws:sqs:us-east-1:123456789012:orders-dlq|task-1",
+ startMessageMoveTaskResult: defaultSQSQueueARN("orders-dlq") + "|task-1",
cancelMessageMoveTaskResult: 7,
listMessageMoveTasksResult: []domain.MessageMoveTask{
{
- TaskHandle: "arn:aws:sqs:us-east-1:123456789012:orders-dlq|task-1",
- SourceArn: "arn:aws:sqs:us-east-1:123456789012:orders-dlq",
- DestinationArn: "arn:aws:sqs:us-east-1:123456789012:orders",
+ TaskHandle: defaultSQSQueueARN("orders-dlq") + "|task-1",
+ SourceArn: defaultSQSQueueARN("orders-dlq"),
+ DestinationArn: defaultSQSQueueARN("orders"),
MaxNumberOfMessagesPerSecond: 12,
ApproximateNumberOfMessagesMoved: 7,
Status: "RUNNING",
@@ -315,7 +327,7 @@ func TestSQSSDKSmokeCoversGovernanceAndRedriveActions(t *testing.T) {
if _, err := client.AddPermission(ctx, &sqs.AddPermissionInput{
QueueUrl: aws.String(queueURL),
Label: aws.String("label-a"),
- AWSAccountIds: []string{"123456789012"},
+ AWSAccountIds: []string{defaultSQSAccountID},
Actions: []string{"SendMessage"},
}); err != nil {
t.Fatalf("add permission: %v", err)
@@ -323,7 +335,7 @@ func TestSQSSDKSmokeCoversGovernanceAndRedriveActions(t *testing.T) {
if got, want := service.addPermissionLabel, "label-a"; got != want {
t.Fatalf("unexpected add permission label: got %q want %q", got, want)
}
- if got, want := service.addPermissionAWSAccountIDs[0], "123456789012"; got != want {
+ if got, want := service.addPermissionAWSAccountIDs[0], defaultSQSAccountID; got != want {
t.Fatalf("unexpected add permission account: got %q want %q", got, want)
}
@@ -385,7 +397,7 @@ func TestSQSSDKSmokeCoversGovernanceAndRedriveActions(t *testing.T) {
if got, want := service.startMessageMoveTaskMaxPerSecond, 12; got != want {
t.Fatalf("unexpected start rate: got %d want %d", got, want)
}
- if got, want := aws.ToString(startOutput.TaskHandle), "arn:aws:sqs:us-east-1:123456789012:orders-dlq|task-1"; got != want {
+ if got, want := aws.ToString(startOutput.TaskHandle), defaultSQSQueueARN("orders-dlq")+"|task-1"; got != want {
t.Fatalf("unexpected task handle: got %q want %q", got, want)
}
@@ -395,7 +407,7 @@ func TestSQSSDKSmokeCoversGovernanceAndRedriveActions(t *testing.T) {
if err != nil {
t.Fatalf("cancel message move task: %v", err)
}
- if got, want := service.cancelMessageMoveTaskTaskHandle, "arn:aws:sqs:us-east-1:123456789012:orders-dlq|task-1"; got != want {
+ if got, want := service.cancelMessageMoveTaskTaskHandle, defaultSQSQueueARN("orders-dlq")+"|task-1"; got != want {
t.Fatalf("unexpected cancel task handle: got %q want %q", got, want)
}
if got, want := cancelOutput.ApproximateNumberOfMessagesMoved, int64(7); got != want {
@@ -415,7 +427,7 @@ func TestSQSSDKSmokeCoversGovernanceAndRedriveActions(t *testing.T) {
if got, want := len(tasksOutput.Results), 1; got != want {
t.Fatalf("unexpected task result count: got %d want %d", got, want)
}
- if got, want := aws.ToString(tasksOutput.Results[0].TaskHandle), "arn:aws:sqs:us-east-1:123456789012:orders-dlq|task-1"; got != want {
+ if got, want := aws.ToString(tasksOutput.Results[0].TaskHandle), defaultSQSQueueARN("orders-dlq")+"|task-1"; got != want {
t.Fatalf("unexpected task result handle: got %q want %q", got, want)
}
if got, want := aws.ToString(tasksOutput.Results[0].DestinationArn), service.QueueARN("orders"); got != want {
@@ -481,7 +493,7 @@ func TestSQSNativeMiddlewareRoutesLifecycleActionThroughService(t *testing.T) {
RegisterSQSNativeRoutes(router, service)
recorder := httptest.NewRecorder()
- request := httptest.NewRequest(http.MethodGet, "/?Action=ListQueues&Version=2012-11-05&QueueNamePrefix=ord&MaxResults=2&NextToken=token-1&QueueOwnerAWSAccountId=123456789012", nil)
+ request := httptest.NewRequest(http.MethodGet, "/?Action=ListQueues&Version=2012-11-05&QueueNamePrefix=ord&MaxResults=2&NextToken=token-1&QueueOwnerAWSAccountId="+defaultSQSAccountID, nil)
router.ServeHTTP(recorder, request)
if !service.listQueuesCalled {
@@ -546,11 +558,11 @@ func (s *stubSQSNativeService) Metadata() orchestrator.Metadata {
}
func (s *stubSQSNativeService) QueueURL(queueName string) string {
- return "https://sqs.us-east-1.amazonaws.com/123456789012/" + queueName
+ return defaultSQSQueueURL(queueName)
}
func (s *stubSQSNativeService) QueueARN(queueName string) string {
- return "arn:aws:sqs:us-east-1:123456789012:" + queueName
+ return defaultSQSQueueARN(queueName)
}
func (s *stubSQSNativeService) CreateQueue(queueName string, attributes map[string]string) (domain.Queue, error) {
diff --git a/core/internal/resources/awscontext/context.go b/core/internal/resources/awscontext/context.go
index 4402718..0f1ac43 100644
--- a/core/internal/resources/awscontext/context.go
+++ b/core/internal/resources/awscontext/context.go
@@ -6,9 +6,10 @@ import (
)
const (
- defaultAccountID = "123456789012"
+ defaultAccountID = "00000000000"
defaultRegion = "us-east-1"
defaultPartition = "aws"
+ defaultEndpoint = "http://127.0.0.1:4566"
)
// Context carries the AWS-facing identity used by local services.
@@ -25,6 +26,7 @@ func Default() Context {
AccountID: defaultAccountID,
Region: defaultRegion,
Partition: defaultPartition,
+ Endpoint: defaultEndpoint,
}
}
@@ -39,6 +41,9 @@ func (c Context) Normalize() Context {
if strings.TrimSpace(c.Partition) == "" {
c.Partition = defaultPartition
}
+ if strings.TrimSpace(c.Endpoint) == "" {
+ c.Endpoint = defaultEndpoint
+ }
c.Endpoint = strings.TrimSpace(c.Endpoint)
return c
}
diff --git a/core/internal/resources/awscontext/context_test.go b/core/internal/resources/awscontext/context_test.go
index 5b81437..201bbe0 100644
--- a/core/internal/resources/awscontext/context_test.go
+++ b/core/internal/resources/awscontext/context_test.go
@@ -15,6 +15,9 @@ func TestDefaultReturnsLocalIdentity(t *testing.T) {
if got, want := ctx.Partition, defaultPartition; got != want {
t.Fatalf("unexpected default partition: got %q want %q", got, want)
}
+ if got, want := ctx.Endpoint, defaultEndpoint; got != want {
+ t.Fatalf("unexpected default endpoint: got %q want %q", got, want)
+ }
}
func TestContextCopyHelpersDoNotMutateOriginal(t *testing.T) {
@@ -35,6 +38,9 @@ func TestContextCopyHelpersDoNotMutateOriginal(t *testing.T) {
if got, want := base.Partition, defaultPartition; got != want {
t.Fatalf("base partition mutated: got %q want %q", got, want)
}
+ if got, want := base.Endpoint, defaultEndpoint; got != want {
+ t.Fatalf("base endpoint mutated: got %q want %q", got, want)
+ }
if got, want := accountOverride.AccountID, "111122223333"; got != want {
t.Fatalf("unexpected overridden account id: got %q want %q", got, want)
}
diff --git a/core/internal/resources/sqs/application/repository_sqlite_test.go b/core/internal/resources/sqs/application/repository_sqlite_test.go
index fcc646d..7df9df6 100644
--- a/core/internal/resources/sqs/application/repository_sqlite_test.go
+++ b/core/internal/resources/sqs/application/repository_sqlite_test.go
@@ -120,7 +120,7 @@ func TestSQLiteRepositoryPersistsQueueAndMessageStateAcrossRestart(t *testing.T)
state.QueuePermissions["queue-a"] = map[string]domain.QueuePermission{
"label-a": {
Label: "label-a",
- AWSAccountIDs: []string{"123456789012"},
+ AWSAccountIDs: []string{defaultSQSAccountID},
Actions: []string{"SendMessage"},
},
}
@@ -128,8 +128,8 @@ func TestSQLiteRepositoryPersistsQueueAndMessageStateAcrossRestart(t *testing.T)
"task-1": {
TaskHandle: "task-1",
SourceQueue: "queue-a",
- SourceArn: "arn:aws:sqs:us-east-1:123456789012:queue-a",
- DestinationArn: "arn:aws:sqs:us-east-1:123456789012:queue-dlq",
+ SourceArn: defaultSQSQueueARN("queue-a"),
+ DestinationArn: defaultSQSQueueARN("queue-dlq"),
MaxNumberOfMessagesPerSecond: 10,
ApproximateNumberOfMessagesMoved: 2,
Status: "RUNNING",
diff --git a/core/internal/resources/sqs/application/service.go b/core/internal/resources/sqs/application/service.go
index 5cb6203..9890397 100644
--- a/core/internal/resources/sqs/application/service.go
+++ b/core/internal/resources/sqs/application/service.go
@@ -1728,7 +1728,11 @@ func queueURLForAccount(queueName, ownerAccountID string) string {
if ownerAccountID = trimName(ownerAccountID); ownerAccountID != "" {
aws = aws.WithAccountID(ownerAccountID)
}
- return fmt.Sprintf("https://sqs.%s.amazonaws.com/%s/%s", aws.Region, aws.AccountID, queueName)
+ endpoint := strings.TrimRight(strings.TrimSpace(aws.Endpoint), "/")
+ if endpoint == "" {
+ endpoint = "http://127.0.0.1:4566"
+ }
+ return fmt.Sprintf("%s/%s/%s", endpoint, aws.AccountID, queueName)
}
func queueARNForAccount(queueName, ownerAccountID string) string {
diff --git a/core/internal/resources/sqs/application/service_test.go b/core/internal/resources/sqs/application/service_test.go
index dace812..1a12028 100644
--- a/core/internal/resources/sqs/application/service_test.go
+++ b/core/internal/resources/sqs/application/service_test.go
@@ -4,16 +4,29 @@ import (
"context"
"crypto/md5"
"encoding/hex"
+ "strings"
"testing"
"time"
"github.com/michasdev/mildstack/core/internal/application/orchestrator"
"github.com/michasdev/mildstack/core/internal/application/runtime"
deliveryhttp "github.com/michasdev/mildstack/core/internal/delivery/http"
+ "github.com/michasdev/mildstack/core/internal/resources/awscontext"
"github.com/michasdev/mildstack/core/internal/resources/sqs/contracts"
"github.com/michasdev/mildstack/core/internal/resources/sqs/domain"
)
+var defaultSQSAccountID = awscontext.Default().AccountID
+
+func defaultSQSQueueURL(queueName string) string {
+ aws := awscontext.Default()
+ return strings.TrimRight(aws.Endpoint, "/") + "/" + aws.AccountID + "/" + queueName
+}
+
+func defaultSQSQueueARN(queueName string) string {
+ return awscontext.Default().ServiceARN("sqs", queueName)
+}
+
func TestSQSServiceMetadataRoutesAndPolicy(t *testing.T) {
t.Helper()
@@ -105,16 +118,16 @@ func TestSQSServiceExposesQueueLifecycleAPI(t *testing.T) {
t.Fatal("expected service to expose queue lifecycle API")
}
- if got, want := service.QueueURL("orders"), "https://sqs.us-east-1.amazonaws.com/123456789012/orders"; got != want {
+ if got, want := service.QueueURL("orders"), defaultSQSQueueURL("orders"); got != want {
t.Fatalf("unexpected queue url helper: got %q want %q", got, want)
}
- if got, want := service.QueueARN("orders"), "arn:aws:sqs:us-east-1:123456789012:orders"; got != want {
+ if got, want := service.QueueARN("orders"), defaultSQSQueueARN("orders"); got != want {
t.Fatalf("unexpected queue arn helper: got %q want %q", got, want)
}
queue, err := service.CreateQueue("orders", map[string]string{
"VisibilityTimeout": "30",
- "RedrivePolicy": `{"deadLetterTargetArn":"arn:aws:sqs:us-east-1:123456789012:orders-dlq"}`,
+ "RedrivePolicy": `{"deadLetterTargetArn":"` + defaultSQSQueueARN("orders-dlq") + `"}`,
})
if err != nil {
t.Fatalf("create queue: %v", err)
@@ -128,7 +141,7 @@ func TestSQSServiceExposesQueueLifecycleAPI(t *testing.T) {
sameQueue, err := service.CreateQueue("orders", map[string]string{
"VisibilityTimeout": "30",
- "RedrivePolicy": `{"deadLetterTargetArn":"arn:aws:sqs:us-east-1:123456789012:orders-dlq"}`,
+ "RedrivePolicy": `{"deadLetterTargetArn":"` + defaultSQSQueueARN("orders-dlq") + `"}`,
})
if err != nil {
t.Fatalf("idempotent create: %v", err)
@@ -187,7 +200,7 @@ func TestSQSServiceExposesQueueLifecycleAPI(t *testing.T) {
if _, err := service.SetQueueAttributes("orders", map[string]string{
"VisibilityTimeout": "45",
"RedriveAllowPolicy": `{"redrivePermission":"byQueue"}`,
- "RedrivePolicy": `{"deadLetterTargetArn":"arn:aws:sqs:us-east-1:123456789012:orders-dlq"}`,
+ "RedrivePolicy": `{"deadLetterTargetArn":"` + defaultSQSQueueARN("orders-dlq") + `"}`,
"ContentBasedDeduplication": "true",
}); err != nil {
t.Fatalf("set queue attributes: %v", err)
@@ -417,7 +430,7 @@ func TestSQSServiceExposesGovernanceAndRedriveSeams(t *testing.T) {
if err := service.TagQueue("queue-a", map[string]string{"env": "dev"}); err != nil {
t.Fatalf("tag queue: %v", err)
}
- if err := service.AddPermission("queue-a", "label-a", []string{"123456789012"}, []string{"SendMessage"}); err != nil {
+ if err := service.AddPermission("queue-a", "label-a", []string{defaultSQSAccountID}, []string{"SendMessage"}); err != nil {
t.Fatalf("add permission: %v", err)
}
@@ -428,11 +441,11 @@ func TestSQSServiceExposesGovernanceAndRedriveSeams(t *testing.T) {
if got, want := tags["env"], "dev"; got != want {
t.Fatalf("unexpected queue tag value: got %q want %q", got, want)
}
- if got, want := service.state.QueuePermissions["queue-a"]["label-a"].AWSAccountIDs[0], "123456789012"; got != want {
+ if got, want := service.state.QueuePermissions["queue-a"]["label-a"].AWSAccountIDs[0], defaultSQSAccountID; got != want {
t.Fatalf("unexpected permission account after add: got %q want %q", got, want)
}
- handle, err := service.StartMessageMoveTask("arn:aws:sqs:us-east-1:123456789012:queue-dlq", "", 10)
+ handle, err := service.StartMessageMoveTask(defaultSQSQueueARN("queue-dlq"), "", 10)
if err != nil {
t.Fatalf("start message move task: %v", err)
}
@@ -596,7 +609,7 @@ func TestSQSServiceAttachStateUsesNamespacedCopySafeSnapshot(t *testing.T) {
"queue-a": map[string]domain.QueuePermission{
"label-a": {
Label: "label-a",
- AWSAccountIDs: []string{"123456789012"},
+ AWSAccountIDs: []string{defaultSQSAccountID},
Actions: []string{"SendMessage"},
},
},
@@ -606,8 +619,8 @@ func TestSQSServiceAttachStateUsesNamespacedCopySafeSnapshot(t *testing.T) {
"task-1": {
TaskHandle: "task-1",
SourceQueue: "queue-a",
- SourceArn: "arn:aws:sqs:us-east-1:123456789012:queue-a",
- DestinationArn: "arn:aws:sqs:us-east-1:123456789012:queue-dlq",
+ SourceArn: defaultSQSQueueARN("queue-a"),
+ DestinationArn: defaultSQSQueueARN("queue-dlq"),
MaxNumberOfMessagesPerSecond: 10,
ApproximateNumberOfMessagesMoved: 2,
Status: "RUNNING",
@@ -788,7 +801,7 @@ func TestSQSServiceQueueAttributesPopulateDeadLetterRecovery(t *testing.T) {
t.Fatalf("create dlq: %v", err)
}
if _, err := service.CreateQueue("queue-a", map[string]string{
- "RedrivePolicy": `{"deadLetterTargetArn":"arn:aws:sqs:us-east-1:123456789012:queue-dlq","maxReceiveCount":"2"}`,
+ "RedrivePolicy": `{"deadLetterTargetArn":"` + defaultSQSQueueARN("queue-dlq") + `","maxReceiveCount":"2"}`,
}); err != nil {
t.Fatalf("create source queue: %v", err)
}
@@ -805,7 +818,7 @@ func TestSQSServiceQueueAttributesPopulateDeadLetterRecovery(t *testing.T) {
}
if _, err := service.SetQueueAttributes("queue-a", map[string]string{
- "RedrivePolicy": `{"deadLetterTargetArn":"arn:aws:sqs:us-east-1:123456789012:queue-dlq","maxReceiveCount":"3"}`,
+ "RedrivePolicy": `{"deadLetterTargetArn":"` + defaultSQSQueueARN("queue-dlq") + `","maxReceiveCount":"3"}`,
}); err != nil {
t.Fatalf("set queue attributes: %v", err)
}
@@ -931,7 +944,7 @@ func TestSQSServiceNewWithPersistenceLoadsRepositoryState(t *testing.T) {
state.QueuePermissions["queue-a"] = map[string]domain.QueuePermission{
"label-a": {
Label: "label-a",
- AWSAccountIDs: []string{"123456789012"},
+ AWSAccountIDs: []string{defaultSQSAccountID},
Actions: []string{"SendMessage"},
},
}
@@ -939,8 +952,8 @@ func TestSQSServiceNewWithPersistenceLoadsRepositoryState(t *testing.T) {
"task-1": {
TaskHandle: "task-1",
SourceQueue: "queue-a",
- SourceArn: "arn:aws:sqs:us-east-1:123456789012:queue-a",
- DestinationArn: "arn:aws:sqs:us-east-1:123456789012:queue-dlq",
+ SourceArn: defaultSQSQueueARN("queue-a"),
+ DestinationArn: defaultSQSQueueARN("queue-dlq"),
MaxNumberOfMessagesPerSecond: 10,
ApproximateNumberOfMessagesMoved: 2,
Status: "RUNNING",
@@ -997,7 +1010,7 @@ func TestSQSServiceNewWithPersistenceLoadsRepositoryState(t *testing.T) {
if got, want := service.state.QueueTags["queue-a"]["env"], "dev"; got != want {
t.Fatalf("unexpected queue tags after load: got %q want %q", got, want)
}
- if got, want := service.state.QueuePermissions["queue-a"]["label-a"].AWSAccountIDs[0], "123456789012"; got != want {
+ if got, want := service.state.QueuePermissions["queue-a"]["label-a"].AWSAccountIDs[0], defaultSQSAccountID; got != want {
t.Fatalf("unexpected queue permission after load: got %q want %q", got, want)
}
if got, want := service.state.MoveTasks["queue-a"]["task-1"].Status, "RUNNING"; got != want {
diff --git a/core/internal/resources/sqs/contracts/message_test.go b/core/internal/resources/sqs/contracts/message_test.go
index a0a0571..ea24384 100644
--- a/core/internal/resources/sqs/contracts/message_test.go
+++ b/core/internal/resources/sqs/contracts/message_test.go
@@ -4,8 +4,15 @@ import (
"encoding/json"
"strings"
"testing"
+
+ "github.com/michasdev/mildstack/core/internal/resources/awscontext"
)
+func defaultSQSQueueURL(queueName string) string {
+ aws := awscontext.Default()
+ return strings.TrimRight(aws.Endpoint, "/") + "/" + aws.AccountID + "/" + queueName
+}
+
func TestMessageContractsPreserveAWSFieldNames(t *testing.T) {
t.Helper()
@@ -26,7 +33,7 @@ func TestMessageContractsPreserveAWSFieldNames(t *testing.T) {
StringValue: "Root=1-12345678-1234567890abcdef12345678",
},
},
- QueueUrl: "https://sqs.us-east-1.amazonaws.com/123456789012/orders",
+ QueueUrl: defaultSQSQueueURL("orders"),
}
data, err := json.Marshal(request)
@@ -40,7 +47,7 @@ func TestMessageContractsPreserveAWSFieldNames(t *testing.T) {
`"MessageDeduplicationId":"dedupe-1"`,
`"MessageGroupId":"group-1"`,
`"MessageSystemAttributes"`,
- `"QueueUrl":"https://sqs.us-east-1.amazonaws.com/123456789012/orders"`,
+ `"QueueUrl":"` + defaultSQSQueueURL("orders") + `"`,
})
batch := SendMessageBatchRequest{
@@ -60,7 +67,7 @@ func TestMessageContractsPreserveAWSFieldNames(t *testing.T) {
`"Entries"`,
`"Id":"entry-1"`,
`"MessageBody":"payload"`,
- `"QueueUrl":"https://sqs.us-east-1.amazonaws.com/123456789012/orders"`,
+ `"QueueUrl":"` + defaultSQSQueueURL("orders") + `"`,
})
sendResult := SendMessageResult{