NOTE
The architecture diagrams embedded below are labeled in Chinese; the text around each diagram explains it in English.
Overview
When buying concert tickets, the most common worries are: "Can I see the stage from this seat? Will a pillar block my view? Is the air conditioning too cold? How long does it take to get out after the show?" Official ticketing sites only show a flat seat map, and the genuinely useful information is scattered across social media posts and comments, with no way to look it up by venue → section → row → seat.
SiyaMap tackles exactly that. It uses the venue seat map as an index: people who have attended a show submit a "real photo of the view + experience rating" for the seat they sat in, and later visitors can click a section on the seat map to see what that seat actually looks like.
| Core concept | Description |
|---|---|
| Venue | Basic information about venues such as Taipei Arena or Kaohsiung Arena: region, transit directions, locker count |
| Seat Map | An interactive venue floor plan drawn in SVG, where each shape maps to a seating section |
| Seat | Section → row → seat number, with standing / seated / accessible seat types |
| Contribution | A user's experience at a specific seat: satisfaction, obstructed view, aisle seat, distance to exit, exit time, air-conditioning strength, comments |
| Contribution Image | Real photos attached to a contribution, automatically watermarked after upload and served in multiple sizes |
Monorepo Architecture
Module Responsibilities
The project is a single pnpm workspace made up of three modules, each with its own build output and deployment target:
siyamap/
├── apps/
│ ├── web/ # @siyamap/web: Next.js 16 SSR pages + Hono REST API + database schema
│ └── image-processor/ # @siyamap/image-processor: watermarking and resizing Lambda (sharp)
├── packages/
│ └── ui/ # @siyamap/ui: shared UI component library + Storybook
├── .github/workflows/ # CI/CD that deploys each module in its own stage
├── pnpm-workspace.yaml # workspace scope + catalog (single React version)
└── turbo.json # task dependencies and caching for build / typecheck / lint
| Module | Responsibility | Deployment target | Used by |
|---|---|---|---|
apps/web | Page rendering, REST API, authentication, database access, issuing upload URLs | Lambda (container image) + S3 static assets | The user's browser |
apps/image-processor | Watermarking originals, on-demand resizing | Lambda (container image) | S3 events, the CDN's /resize/* path |
packages/ui | Shared React components and design tokens | Workspace package + static Storybook site | Bundled by apps/web at build time |
Request Flow Between Modules
The diagram below shows how the three modules divide the work with external services, along with the three main request flows:
- Pages and API: browser → CloudFront →
apps/web(SSR pages and the Hono API run in the same Lambda) → Neon PostgreSQL - Image upload: the browser first gets a presigned URL from
apps/web, then uploads straight to S3; the S3 event triggersapps/image-processorto add the watermark - Image read: browser →
cdn.siyamap.com; original sizes are served directly from S3, while/resize/*is handed toapps/image-processorfor on-the-fly resizing
The three modules never call each other's code directly: web and image-processor cooperate through S3 folder conventions (origin/, watermark/, static/) and the CDN URL format, while ui is only bundled into web at build time. Any module can be deployed on its own without affecting the others.
Engineering Setup
- pnpm Catalog: the versions of React, React DOM and their types are defined once in
pnpm-workspace.yamland referenced by every module throughcatalog:, preventing multiple copies of React in the monorepo and the hooks errors that come with them - Turborepo task dependencies:
builddeclaresdependsOn: ["^build"], guaranteeing thatwebis bundled against the latest compiledui;typecheckis cached, so unchanged modules are not re-checked - Husky pre-commit: runs
turbo typecheckbefore every commit, so a type error in any module cannot be committed - Shared lint rules: the root ESLint flat config (
typescript-eslint,simple-import-sort,eslint-plugin-storybook) and Prettier apply to every module
apps/web: Next.js Full-Stack App
Responsibilities
apps/web is the core of the system and is responsible for:
- Pages: Next.js 16 App Router SSR pages (venue list, seat map, upload, profile, login)
- REST API:
/api/*built with Hono, covering eight domain modules:auth,oauth,user,profile,file,venue,seat-mapandhealth-check - Data layer: Drizzle ORM schema definitions and data access, covered in the next chapter, "Database Design"
Integration with Other Modules
| Counterpart | How |
|---|---|
packages/ui | Depends on "@siyamap/ui": "workspace:*"; next.config.ts sets transpilePackages: ['@siyamap/ui'] so it is compiled together at build time |
apps/image-processor | No direct calls. POST /api/files uses withWatermark to decide whether the presigned URL targets origin/ (needs a watermark) or static/ (does not); image-processor only listens to events from origin/ |
| S3 | Only issues presigned PUT URLs; the images themselves never pass through this Lambda |
| Neon PostgreSQL | Queried through the HTTP driver of @neondatabase/serverless |
SSR and API Share One Deployment Unit
The API is not a separate server. Hono is mounted on a Next.js catch-all route handler (app/api/[[...route]]/route.ts), so the SSR pages and the REST API ship as one Docker image and one Lambda. For a side project, that means a single deployment, a single place to monitor and a single bill; and because the API layer is written in framework-agnostic Hono, it can be moved as-is to its own Lambda or container once traffic justifies splitting it out.
Backend Layering and Request Pipeline
Every domain module follows the same three-layer structure:
server/
├── middlewares/
│ ├── auth.middleware.ts # setUser / requireLoggedIn / requireAdmin
│ └── validator.middleware.ts # wraps zValidator with a unified validation error format
├── modules/
│ └── venue/
│ ├── venue.route.ts # HTTP binding + middleware chain + Swagger JSDoc
│ ├── venue.controller.ts # parses requests, builds responses, maps error codes
│ └── venue.service.ts # data access and business logic (Drizzle)
└── schema/ # Drizzle table definitions + relations
types/dto/ # Zod DTOs (shared by frontend and backend)
The full path of a request from entering Hono to the response, and which errors each layer stops:
Dependency Injection (Constructor Injection)
Controllers and services receive their dependencies through constructor parameters, with default values that provide the production instances; each module exports a pre-assembled singleton:
export class FileService {
constructor(
private readonly db = _db,
private readonly s3Client = _s3Client,
private readonly getSignedUrl = _getSignedUrl
) {}
// ...
}
export default new FileService()
This avoids the complexity of a DI container while keeping DI's most important benefit: a unit test can simply call new FileService(mockDb, mockS3, mockSigner) without monkey-patching modules. Even functions such as bcrypt.compare and setSessionCookie are injected, so the authentication flow can be tested without touching real hashing or cookies.
Zod DTOs: One Schema, Three Uses
DTOs are defined with Zod, and each operation's variant is derived from one base schema with pick / omit / partial, so every field is defined only once:
export const InsertVenueDto = VenueDto.pick({ name: true, area: true, address: true, /* ... */ })
export const UpdateVenueDto = VenueDto.omit({ id: true, status: true, createdAt: true, updatedAt: true }).partial()
| Use | Where | Effect |
|---|---|---|
| Request validation | reqValidator.json(InsertVenueDto) | Invalid bodies / params get a 400 before reaching the controller |
| Response filtering | VenueDto.parse(venue) | Responses only include fields defined in the DTO, so new database columns never leak by accident |
| Type inference | z.infer<typeof InsertVenueDto> | The frontend API client and the backend share one type; changing it breaks both sides at compile time |
Unified Error Handling
- An
ErrorCodeunion type defines every error code in one place (UNAUTHORIZED,DATA_NOT_FOUND,CSRF_ERROR…) and is shared by frontend and backend - Every response goes through
ResponseFormatter: success is always{ data, pagination? }, failure is always{ code, message, details? }; status codes that must not carry a body, such as 204 / 304, are handled correctly - Hono's
app.onErroris the last line of defense, turning any unexpected exception intoINTERNAL_SERVER_ERROR - The frontend
RequestClientnormalizes non-2xx responses and network errors into the sameApiErrorResponseshape, andgetErrorConfigmaps each code to a user-friendly message
Authentication and Authorization
| Mechanism | Implementation |
|---|---|
| Email / password | Stored as a bcrypt hash (10 salt rounds) |
| Google OAuth 2.0 | Authorization Code Flow without the Google SDK, calling the token and userinfo endpoints directly; accounts are created automatically with a find-or-insert by email |
| CSRF protection | Generates a 64-byte random token stored in an httpOnly cookie (5 minutes) and, together with the "page to return to after login", base64-encoded into the OAuth state; the callback compares the two and deletes the cookie immediately to prevent replay |
| Session | HS256 JWT signed with jose, stored in an httpOnly, SameSite=Lax cookie (Secure in production) for 7 days; frontend JavaScript cannot read it |
| Authorization (RBAC) | A global setUser middleware parses the session and puts the user into the Hono context; admin operations such as writing venues and seat maps add requireAdmin, and operations such as file uploads add requireLoggedIn |
Page Route Guards: Chain of Responsibility
Page-level access control (/api-doc visible only in development, pages that require login, and /login, which logged-in users should not see) is written as several FunctionFactory guards, composed back-to-front into a single Next.js proxy by a generic chain():
export default chain(
[withDevOnly, withLoggedInOnly, withLoggedOutOnly],
0,
() => NextResponse.next()
)
Each guard does exactly one thing and hands off to the next when it has nothing to intercept; adding a rule only takes a new factory added to the array, where array order is priority.
Isomorphic API Client and Data Fetching
RequestClient runs both in Server Components and in the browser. On the server it dynamically imports next/headers to read the current request's cookies and forwards them to the API, so authenticated endpoints can be called as the user during SSR; in the browser it uses credentials: 'include' instead. Pages fetch data on the server first and hand it to the client through React Query's initialData, so the first screen needs no extra request; subsequent creates, updates and deletes run as mutations followed by invalidateQueries to resync.
API Documentation
Each route is described with OpenAPI 3.0 JSDoc above it; next-swagger-doc scans server/modules to generate the spec automatically, and Swagger UI renders it at /api-doc, which the proxy restricts to development only.
Database Design (apps/web)
The database schema lives in apps/web/server/schema/. The Drizzle ORM TypeScript definitions are the source of truth (schema-as-code), with a DBML file maintained alongside as design documentation; on deployment, CI syncs the schema to Neon PostgreSQL with drizzle-kit push.
ER Diagram
There are 9 tables in three domains: users and files (users, files), contributions (contributions, contribution_images), and venues and seats (venues, seat_maps, seat_areas, seats, restrooms).
Primary Keys: UUID v7
Every table uses an application-generated UUID v7 primary key ($defaultFn(() => uuidv7())):
- Time-ordered: the first 48 bits are a millisecond timestamp, so new rows always land at the end of the B-tree index, avoiding the page splits and index bloat caused by random UUID v4 inserts
- Non-enumerable: IDs are used directly in the public API and in image file names; unlike auto-increment integers, they don't reveal data volume or invite sequential scraping
- Generated in the application: the ID is known before the write, so it can be used to build the S3 file name first and then inserted, without waiting for the database to return it
Modeling the Venue and Seat Hierarchy
Seat data follows a venue → seat map → seat area → row → seat hierarchy:
seat_maps.map_svgstores the whole SVG seat map as text. The seat map editor sanitizes the exported SVG with DOMPurify (USE_PROFILES: { svg: true }) to prevent stored XSS from scripts embedded in SVG; each shape in the SVG (rect/polygon/path) maps to oneseat_areasrowseat_areas → seat_mapsusesON DELETE CASCADE: seat areas are part of a seat map, so re-uploading a seat map should remove the old areas with itseatsdeliberately denormalizesvenue_idin addition toseat_area, so listing all seats of a venue doesn't require joining three tables- The composite index
(seat_area, seat_row)matches the main query path on the frontend: click a section → list seats by row
Contributions and Images
contributionsreferences bothuser_idandseat_idand denormalizesvenue_id, with three indexes onvenue_id,user_idandcreated_atsupporting the venue page's contribution list, "my contributions" and the latest-contributions timeline respectivelysatisfactionusesNUMERIC(2,1)(0.0–9.9) instead of a float to avoid precision errors when averaging ratingsfilesis a single file registry that records only the owner, URL and file name; domain tables reference it by foreign key instead of storing URLs themselves.contribution_imageshas two foreign keys tofiles:file_id_with_watermark(NOT NULL): the publicly displayed versionfile_id_without_watermark(NULLABLE): the original without a watermark, recorded separately from the public version
- When one table has two relations to another, Drizzle distinguishes them with
relationName, so relational queries such aswith: { fileWithWatermark: true }can state the direction explicitly
Other Design Details
| Design | Notes |
|---|---|
| PostgreSQL enums | Bounded domains such as user_role, venue_status, taiwan_area, seat_type, distance and aircon_strength use pgEnum, rejecting invalid values at the database level |
timestamptz | Every timestamp column stores a time zone; updated_at is written automatically on every update through Drizzle's $onUpdate |
Nullable users.password | Accounts registered through Google OAuth have no password; the login API uses this to return USER_FROM_OAUTH, prompting the user to sign in with the third-party provider |
users.metadata (JSONB) | Reserved for user attributes that are not settled yet, avoiding frequent migrations for experimental fields |
| Venue draft status | venues.status defaults to DRAFT; admins publish a venue only after its seat map and details are complete |
apps/image-processor: Image Processing Service
Responsibilities
Contribution photos are the platform's core content and also the assets most likely to be stolen. image-processor is a standalone Lambda with two jobs:
- Watermarking: after an original is uploaded to
origin/, it automatically produces a watermarked version inwatermark/ - On-demand resizing: requests to
cdn.siyamap.com/resize/w_{width}/{folder}/{key}are resized on the fly and cached by CloudFront
Image uploads and processing never pass through the apps/web Lambda, so large files don't consume SSR execution time or memory.
Integration with Other Modules
There are no API calls between image-processor and apps/web. They cooperate only through S3 folder conventions and the same bucket environment variable (AWS_S3_BUCKET_IMAGE_UPLOAD): web decides which folder a file is written to, and image-processor decides from the folder whether to process it. The diagram below shows the full sequence of an image from upload to being viewed:
One Lambda, Two Event Sources
Watermarking (S3 events) and resizing (API Gateway HTTP events) are two completely different triggers, but they share the native sharp module, the S3 client and utility functions. So both are packaged into the same container image, and the entry point uses a TypeScript type guard to identify the event type and dispatch to the matching handler (Strategy pattern), leaving only one deployment unit to maintain:
export const handler = async (event: S3Event | APIGatewayProxyEventV2) => {
if (isS3Event(event)) return watermarkHandler(event)
if (isApiGatewayProxyEvent(event)) return resizeHandler(event)
throw new Error(`[錯誤] 不支援的事件類型: ${JSON.stringify(event)}`)
}
Watermark Algorithm
The goal is a diagonal, staggered, low-opacity watermark that is hard to crop out yet doesn't get in the way of viewing:
- Scale the watermark to 10% of the image width and use
linear()to scale only the alpha channel, bringing opacity down to 10% - Tile it in a staggered brick pattern: every other row starts half a step further along
- Make the tiling canvas as wide as the image's diagonal plus a margin, so the whole layer still covers all four corners after rotating it -45°
- Center-crop the rotated layer back to the image size, then composite it over the original and output a JPEG
NOTE
In a sharp pipeline, rotate always runs before composite, regardless of the order of the calls in code. So the tiled result has to be rendered to a buffer first and rotated in a new pipeline; otherwise the rotation applies to the empty canvas rather than the tiled watermark.
Abuse Protection on the Resize Endpoint
The resize URL's width only accepts the whitelisted values 200, 400 and 800, with withoutEnlargement enabled. If any width were allowed, an attacker could keep changing the width parameter to force a cache miss on every request and hit the Lambda, which is billed by execution time, directly: a cost attack. The whitelist also keeps CloudFront's cache hit rate predictable.
Pitfall: 403 on Presigned URL Uploads
This problem sits at the boundary between web and S3. Since around version 3.370, AWS SDK v3 defaults to requestChecksumCalculation: 'when_supported', which automatically adds x-amz-checksum-crc32 to presigned URLs, so S3 then requires the PUT request to carry a matching CRC32 header. The browser's fetch doesn't compute that value, so every direct upload returned 403. The fix is to set requestChecksumCalculation: 'WHEN_REQUIRED' on the S3 client, computing checksums only when the API requires them.
Testing
Both watermarking and resizing have processor tests written with Jest. addWatermarkToImage uses function overloads to support both "write to a file" and "return a Buffer", making it easy to inspect the output on real images locally.
packages/ui: Shared Component Library
Responsibilities
@siyamap/ui is the component library that connects implementation to the UI design, providing shared components such as buttons and icons along with the Tailwind theme. Components are developed and showcased in isolation in Storybook, so designs and implementation can be aligned without starting the whole app.
Integration with Other Modules
- Used by
apps/web:webdepends on it throughworkspace:*; Turborepo makes sureuiis built first, andwebthen compiles it into its own bundle withtranspilePackages - Storybook deployed independently: CI syncs
storybook-staticto S3, served through CloudFront atstage.ui.siyamap.com, independently ofweb's deployment
Bundling Design
- tsup outputs both CJS and ESM plus type declarations, with code splitting and minification enabled, and
react/react-dommarked external to avoid bundling them twice - A
"use client"banner is injected at the top of the bundle, so components can be imported directly from Next.js App Router Server Components without wrapping each use in a client component - Each component has a subpath export (such as
@siyamap/ui/Icon), so only the components in use are imported, which helps tree-shaking - React is listed under
peerDependencies, with its version managed centrally by the pnpm Catalog
Storybook and Testing
- Storybook 10 with the a11y addon shows accessibility checks while components are being built
- Vitest browser mode runs component tests in a real browser through Playwright, which is closer to actual rendering behavior than jsdom
Infrastructure and Deployment
AWS Architecture
Every service runs in AWS ap-southeast-1. After Route 53 resolves the four domains, traffic is routed by domain and path to different CloudFront distributions and backends:
Why Serverless
This is a side project with low, unpredictable traffic, so the top priority is "don't pay when nobody is using it". Every layer therefore uses pay-per-use services that scale to zero:
| Need | Choice | Reason |
|---|---|---|
| SSR and API compute | Lambda + Lambda Web Adapter | Billed by execution time and free when idle; the Web Adapter runs the Next.js standalone server.js without rewriting it as a Lambda handler |
| Deployment unit | Container image (ECR) | The container image size limit (10 GB) is far higher than zip deployments, so the Next.js standalone output and native modules such as sharp never hit a size ceiling; Docker also keeps local and production environments identical |
| Static assets | S3 + CloudFront | Hashed _next/static files are served with max-age=31536000, immutable and never reach Lambda |
| Database | Neon Serverless PostgreSQL | Auto-suspends when idle; the HTTP driver (drizzle-orm/neon-http) makes every query a stateless HTTP request, avoiding the classic problem of exhausting TCP connection pools under Lambda cold starts |
| Cloud permissions | IAM execution role | The AWS SDK inside Lambda gets temporary STS credentials from the role, so no long-lived access keys live in code or environment variables |
CI/CD Pipeline
On a push to the stage branch, a single GitHub Actions workflow deploys the three modules in sequence. Every Docker image is tagged with the git commit SHA, so any deployment can be traced and rolled back to the exact code version.
| Stage | Highlights |
|---|---|
| Shared build | Besides the private ECR, also logs in to the public ECR to avoid anonymous rate limits when pulling the Lambda Web Adapter and Lambda base images from public.ecr.aws |
packages/ui | Hashed assets need no cache clearing, so only the two entry files index.html and iframe.html are invalidated, shortening the time to go live |
apps/web | Updates the database schema with drizzle-kit push before switching the code; lambda wait function-updated ensures the new version is ready before static assets are synced |
apps/image-processor | Built and deployed to its own Lambda separately from web, so it is deployed and scaled independently |
Static assets get different cache policies depending on whether the file name is hashed: _next/static is cached for one year as immutable, while unhashed files in public/ are cached for only one hour.


コメント(0)
まだコメントはありません。最初のコメントを書いてみませんか?