Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Shell-executable scripts carry a shebang (#!/usr/bin/env node). A CRLF shebang
# breaks execution on Unix and breaks esbuild/vite hashbang stripping when the
# file is imported from a test (test/skill.test.ts fails to parse on a CRLF
# checkout). Force LF regardless of the contributor's core.autocrlf.
*.mjs text eol=lf
198 changes: 198 additions & 0 deletions docs/superpowers/plans/2026-07-05-cloudfront-directory-redirect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# cloudfront 디렉터리 인덱스 301 리다이렉트 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** cloudfront 모드에서 `/<code>`(슬래시 없음) 접속 시 `/<code>/`로 301 리다이렉트해, 상대경로 자산이 루트로 새어 흰 화면이 되는 버그(#44)를 없앤다.

**Architecture:** `infra/index-rewrite.js`(CloudFront Function, viewer-request)의 확장자-없음+슬래시-없음 분기를 내부 rewrite에서 301 리다이렉트로 전환한다. 쿼리스트링을 재구성해 `Location` 헤더에 보존한다. 순수 함수라 `vm` 샌드박스로 로드해 단위 테스트한다.

**Tech Stack:** CloudFront Functions (cloudfront-js-2.0, ES5 문법), Vitest, node `vm`.

## Global Constraints

- CloudFront Functions 런타임은 **ES5**만 지원 — `var`만 사용, `const`/`let`/화살표함수/템플릿리터럴/`for...of` 금지 (기존 `infra/index-rewrite.js` 스타일 준수).
- 분기 순서 불변: ①`/_*`→403 → ②트레일링 슬래시→index.html → ③확장자 없음→301 → ④파일 통과.
- CI는 AWS/Terraform 없이 build→typecheck→test만 실행 — 테스트는 함수 소스를 `vm`으로 로드하는 순수 로직 검증만.
- 테스트 러너: `npx vitest run test/index-rewrite.test.ts`.

---

### Task 1: `/<code>` → `/<code>/` 301 리다이렉트 (쿼리스트링 보존)

**Files:**
- Modify: `infra/index-rewrite.js` (확장자-없음 분기 + `buildQueryString` 헬퍼 추가)
- Test: `test/index-rewrite.test.ts` (rewrite 기대값 2건 교체 + 신규 케이스 추가)

**Interfaces:**
- Consumes: CloudFront Function 이벤트 `{ request: { uri, querystring, headers } }`. `querystring`은 `{ key: { value } }` 또는 반복 파라미터 시 `{ key: { multiValue: [{ value }, ...] } }`.
- Produces: `handler(event)` 반환값 —
- `/_*`: `{ statusCode: 403, statusDescription: "Forbidden" }`
- 트레일링 슬래시/파일: `request`(수정된 `uri`)
- 확장자 없음+슬래시 없음: `{ statusCode: 301, statusDescription: "Moved Permanently", headers: { location: { value } } }`
- 내부 헬퍼 `buildQueryString(qs) -> string` (`""` 또는 `"?..."`)

- [ ] **Step 1: 실패하는 테스트 작성**

`test/index-rewrite.test.ts`에서 `reqEvent` 헬퍼를 querystring 지원하도록 확장하고, 확장자-없음 케이스 2건을 redirect 기대값으로 교체 후 신규 케이스를 추가한다.

기존 헬퍼(파일 상단, 현재 16행):
```ts
const reqEvent = (uri: string) => ({ request: { uri, headers: {} } });
```
를 다음으로 교체:
```ts
const reqEvent = (uri: string, querystring: Record<string, unknown> = {}) => ({
request: { uri, querystring, headers: {} },
});
```

기존 테스트 2건 교체 — 현재:
```ts
it("appends /index.html for an extensionless URI", () => {
const out = handler(reqEvent("/x7Kq2a"));
expect(out.uri).toBe("/x7Kq2a/index.html");
});
```
```ts
it("appends /index.html for a nested extensionless URI", () => {
const out = handler(reqEvent("/team/q1/report"));
expect(out.uri).toBe("/team/q1/report/index.html");
});
```
를 다음으로 교체:
```ts
it("301-redirects an extensionless URI to add a trailing slash", () => {
const out = handler(reqEvent("/x7Kq2a"));
expect(out.statusCode).toBe(301);
expect(out.statusDescription).toBe("Moved Permanently");
expect(out.headers.location.value).toBe("/x7Kq2a/");
});

it("301-redirects a nested extensionless URI to add a trailing slash", () => {
const out = handler(reqEvent("/team/q1/report"));
expect(out.statusCode).toBe(301);
expect(out.headers.location.value).toBe("/team/q1/report/");
});
```

신규 케이스 추가(같은 describe 블록 안, 위 redirect 테스트들 뒤):
```ts
it("preserves the query string in the redirect location", () => {
const out = handler(
reqEvent("/x7Kq2a", { a: { value: "1" }, b: { value: "2" } }),
);
expect(out.headers.location.value).toBe("/x7Kq2a/?a=1&b=2");
});

it("omits the '?' when there is no query string", () => {
const out = handler(reqEvent("/x7Kq2a"));
expect(out.headers.location.value).toBe("/x7Kq2a/");
});

it("keeps a valueless flag parameter without '='", () => {
const out = handler(reqEvent("/x7Kq2a", { flag: { value: "" } }));
expect(out.headers.location.value).toBe("/x7Kq2a/?flag");
});

it("expands a repeated (multiValue) parameter", () => {
const out = handler(
reqEvent("/x7Kq2a", { k: { multiValue: [{ value: "1" }, { value: "2" }] } }),
);
expect(out.headers.location.value).toBe("/x7Kq2a/?k=1&k=2");
});
```

- [ ] **Step 2: 테스트 실행 → 실패 확인**

Run: `npx vitest run test/index-rewrite.test.ts`
Expected: FAIL — 교체된 케이스는 `out.uri`가 아닌 `out.statusCode`/`out.headers`를 기대하나 현재 핸들러는 `uri`를 rewrite해 `out.statusCode`가 `undefined`, `out.headers`가 `undefined`라 `location` 접근 시 실패. 신규 케이스도 동일하게 실패.

- [ ] **Step 3: 핸들러 구현**

`infra/index-rewrite.js` 전체를 다음으로 교체:
```js
// CloudFront Function (runtime: cloudfront-js-2.0), viewer-request.
// - Protect the private `_meta/` prefix: any "/_*" path returns 403.
// - Directory index: CloudFront's Default Root Object applies only to "/".
// Trailing-slash URIs get "index.html" appended (internal rewrite).
// Extensionless URIs without a trailing slash 301-redirect to add the
// slash, so relative assets in index.html resolve under the subpath
// instead of leaking to the domain root.
function handler(event) {
var request = event.request;
var uri = request.uri;

if (uri.indexOf("/_") === 0) {
return { statusCode: 403, statusDescription: "Forbidden" };
}

if (uri.endsWith("/")) {
request.uri = uri + "index.html";
return request;
}

var lastSegment = uri.substring(uri.lastIndexOf("/") + 1);
if (lastSegment.indexOf(".") === -1) {
return {
statusCode: 301,
statusDescription: "Moved Permanently",
headers: { location: { value: uri + "/" + buildQueryString(request.querystring) } },
};
}

return request;
}

// {k:{value}} or {k:{multiValue:[{value},...]}} -> "?k=v&k=v2" ("" if empty).
function buildQueryString(qs) {
var parts = [];
for (var key in qs) {
var v = qs[key];
if (v.multiValue) {
for (var i = 0; i < v.multiValue.length; i++) {
parts.push(key + "=" + v.multiValue[i].value);
}
} else {
parts.push(v.value === "" ? key : key + "=" + v.value);
}
}
return parts.length ? "?" + parts.join("&") : "";
}
```

- [ ] **Step 4: 테스트 실행 → 통과 확인**

Run: `npx vitest run test/index-rewrite.test.ts`
Expected: PASS — 전체 케이스(교체 2 + 신규 4 + 회귀 유지: 트레일링 슬래시 2, 파일 통과 1, `_meta` 403 2) 통과.

- [ ] **Step 5: 커밋**

```bash
git add infra/index-rewrite.js test/index-rewrite.test.ts
git commit -m "fix: 301-redirect extensionless URIs to trailing slash (#44)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

### Task 2: 전체 테스트·타입체크·빌드 회귀 확인

**Files:** (변경 없음 — 검증 전용)

- [ ] **Step 1: 전체 테스트**

Run: `npm test`
Expected: PASS — 기존 전체 스위트가 무회귀로 통과.

- [ ] **Step 2: 타입체크**

Run: `npm run typecheck`
Expected: 에러 없음.

- [ ] **Step 3: 빌드**

Run: `npm run build`
Expected: 에러 없이 `dist/` 생성. (`infra/`는 tsc 대상이 아니므로 함수 변경은 빌드에 영향 없지만, 테스트 파일 변경이 빌드/타입에 영향 없음을 확인.)

이 태스크는 코드 변경이 없으므로 별도 커밋 없음.
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# cloudfront 디렉터리 인덱스 301 리다이렉트

- **이슈**: [#44](https://github.com/jkas2016/hostdoc/issues/44) — 폴더 배포 시 trailing slash 없는 URL 흰 화면
- **날짜**: 2026-07-05
- **모드 영향**: cloudfront 전용 (s3-website는 변경 없음)

## 문제

cloudfront 모드로 배포한 폴더(멀티파일) 문서는 공유 링크에서 trailing slash가 빠지면 흰 화면이 된다.

현재 CloudFront Function(`infra/index-rewrite.js`)은 확장자 없는 URI(`/apple-game`)를 **내부 rewrite**로 `/apple-game/index.html`을 그대로 200 서빙한다. 이때 브라우저의 주소는 슬래시 없이 `/apple-game`으로 남고, `index.html`이 참조하는 상대경로 자산 `./assets/...`는 도메인 루트 `/assets/...`로 새어 403이 된다. 앱이 부팅하지 못하고 **콘솔 에러 없는 흰 화면**이 된다.

```
GET /apple-game → 200 (index.html, 주소는 슬래시 없음)
GET /assets/index-*.js → 403 (상대경로가 루트로 샘)
GET /apple-game/assets/... → 200 (슬래시 있으면 정상)
```

근본 원인은 엣지 라우팅이다. 앱은 상대경로로 올바르게 빌드되어 있어 수정 대상이 아니다.

### 왜 cloudfront만 문제인가

- **s3-website 모드**: S3 정적 웹사이트 호스팅이 `IndexDocument`를 설정하면([`src/commands/setup.ts:50`](../../../src/commands/setup.ts)) 폴더형 요청 `/foo`를 `/foo/`로 네이티브 302 리다이렉트한다. 이미 정상 동작.
- **cloudfront 모드**: 오리진이 비공개 S3(OAC)라 웹사이트 엔드포인트의 디렉터리 리다이렉트가 없다. 라우팅을 CloudFront Function이 전담하며, 현재 그 함수가 슬래시를 붙이지 않는다.

## 기대 동작

정적 호스트의 표준 디렉터리 인덱스 동작: 디렉터리 코드 URL `/<code>` 접속 시 엣지가 `/<code>/`로 **301 리다이렉트**한다. 그러면 브라우저 주소에 슬래시가 붙고 상대경로 자산이 항상 올바른 서브패스로 풀린다.

## 설계

### 변경 대상

- `infra/index-rewrite.js` — CloudFront Function (viewer-request)
- `test/index-rewrite.test.ts` — 순수 핸들러 로직 테스트

### 로직 (분기 순서 유지)

| # | 조건 | 동작 | 변경 |
|---|------|------|------|
| 1 | URI가 `/_`로 시작 | `403 Forbidden` | 유지 |
| 2 | URI가 `/`로 끝남 | `index.html` 부착 (내부 rewrite) | 유지 |
| 3 | 확장자 없음 + 슬래시 없음 | **`301 → uri + "/"` (쿼리스트링 보존)** | **변경** |
| 4 | 확장자 있는 파일 | 그대로 통과 | 유지 |

3번 분기가 기존의 내부 rewrite(`uri + "/index.html"`)에서 **301 리다이렉트**로 바뀐다. 리다이렉트 후 브라우저가 슬래시 URL로 재요청하면 2번 분기가 `index.html`을 서빙한다.

적용 범위는 **모든 확장자 없는 URI** — 최상위 코드(`/apple-game`)뿐 아니라 중첩 경로(`/team/q1/report`)도 동일하게 301로 슬래시를 붙인다. 동작이 균일하고 중첩 폴더의 상대경로 자산도 항상 올바르게 풀린다.

### 쿼리스트링 보존

CloudFront Function의 `event.request.querystring`은 `{ key: { value }, ... }` 형태의 맵이다. `multiValue`가 있는 키(반복 파라미터)까지 포함해 원본 쿼리 문자열을 재구성하여 `Location` 헤더에 이어 붙인다. 쿼리스트링이 비면 `?`를 붙이지 않는다.

```js
function handler(event) {
var request = event.request;
var uri = request.uri;

if (uri.indexOf("/_") === 0) {
return { statusCode: 403, statusDescription: "Forbidden" };
}

if (uri.endsWith("/")) {
request.uri = uri + "index.html";
return request;
}

var lastSegment = uri.substring(uri.lastIndexOf("/") + 1);
if (lastSegment.indexOf(".") === -1) {
var location = uri + "/" + buildQueryString(request.querystring);
return {
statusCode: 301,
statusDescription: "Moved Permanently",
headers: { location: { value: location } },
};
}

return request;
}

// {k:{value}} 또는 {k:{multiValue:[{value}...]}} → "?k=v&k=v2" ("" if empty)
function buildQueryString(qs) {
var parts = [];
for (var key in qs) {
var v = qs[key];
if (v.multiValue) {
for (var i = 0; i < v.multiValue.length; i++) {
parts.push(key + "=" + v.multiValue[i].value);
}
} else {
parts.push(v.value === "" ? key : key + "=" + v.value);
}
}
return parts.length ? "?" + parts.join("&") : "";
}
```

### 동작 결과

| 접속 URL | 응답 | 최종 |
|---|---|---|
| `/apple-game` | 301 → `/apple-game/` | 슬래시 URL 재요청 → index.html 200, 상대경로 자산 정상 ✅ |
| `/apple-game/` | 200 index.html | 정상 ✅ |
| `/apple-game?level=3` | 301 → `/apple-game/?level=3` | 쿼리 보존 ✅ |
| `/team/q1/report` | 301 → `/team/q1/report/` | 중첩도 정상 ✅ |
| `/apple-game/assets/app.js` | 통과 | 200 ✅ |
| `/_meta/x.json` | 403 | 차단 ✅ |

## 테스트 계획 (TDD — 구현 전 작성)

`test/index-rewrite.test.ts`는 `vm`으로 함수 소스를 로드해 순수 핸들러를 검증한다. `reqEvent` 헬퍼에 `querystring`을 넣을 수 있도록 확장한다.

**변경(rewrite → redirect 기대값 교체):**
- `/x7Kq2a` → `statusCode` 301, `headers.location.value` = `/x7Kq2a/`
- `/team/q1/report` → 301, `location` = `/team/q1/report/`

**추가(쿼리스트링 보존):**
- `/x7Kq2a` + `querystring {a:{value:"1"}, b:{value:"2"}}` → `location` = `/x7Kq2a/?a=1&b=2`
- 쿼리 없음 → `location`에 `?` 없음 (`/x7Kq2a/`)
- 값 없는 플래그 파라미터 `{flag:{value:""}}` → `location` = `/x7Kq2a/?flag`
- 반복 파라미터 `multiValue` → `?k=v1&k=v2`

**회귀(유지 검증):**
- `/x7Kq2a/` → `uri` = `/x7Kq2a/index.html`
- `/team/q1/report/` → `uri` = `/team/q1/report/index.html`
- `/x7Kq2a/assets/app.js` → `uri` 그대로
- `/_meta/x7Kq2a.json` → 403
- `/_meta/team/q1/report.json` → 403

## 배포 주의사항

이 함수 수정은 코드 변경만으로 기존 배포된 CloudFront distribution에 반영되지 않는다. 사용자가 최신 hostdoc으로 **재-provision**(`terraform apply`) 해야 새 함수가 적용된다. 릴리스 노트/이슈에 이 점을 명시한다.

## 범위 밖 (YAGNI)

- **SPA 클라이언트 라우팅 폴백**: `/apple-game/level/3` 같은 존재하지 않는 경로를 index.html로 폴백하는 것은 별개 기능. 현재도(그리고 변경 후에도) 301→슬래시→index.html 미존재로 404가 되며, 이 이슈 범위가 아니다.
- **s3-website 모드**: 이미 정상이라 손대지 않는다.
- **앱/업로드 레벨 수정**: 근본 원인이 엣지라 대상 아님.
38 changes: 31 additions & 7 deletions infra/index-rewrite.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// CloudFront Function (runtime: cloudfront-js-2.0), viewer-request.
// - Protect the private `_meta/` prefix: any "/_*" path returns 403.
// - Subdirectory index: CloudFront's Default Root Object applies only to "/",
// so append "index.html" for trailing-slash or extensionless URIs.
// - Directory index: CloudFront's Default Root Object applies only to "/".
// Trailing-slash URIs get "index.html" appended (internal rewrite).
// Extensionless URIs without a trailing slash 301-redirect to add the
// slash, so relative assets in index.html resolve under the subpath
// instead of leaking to the domain root.
function handler(event) {
var request = event.request;
var uri = request.uri;
Expand All @@ -12,12 +15,33 @@ function handler(event) {

if (uri.endsWith("/")) {
request.uri = uri + "index.html";
} else {
var lastSegment = uri.substring(uri.lastIndexOf("/") + 1);
if (lastSegment.indexOf(".") === -1) {
request.uri = uri + "/index.html";
}
return request;
}

var lastSegment = uri.substring(uri.lastIndexOf("/") + 1);
if (lastSegment.indexOf(".") === -1) {
return {
statusCode: 301,
statusDescription: "Moved Permanently",
headers: { location: { value: uri + "/" + buildQueryString(request.querystring) } },
};
}

return request;
}

// {k:{value}} or {k:{multiValue:[{value},...]}} -> "?k=v&k=v2" ("" if empty).
function buildQueryString(qs) {
var parts = [];
for (var key in qs) {
var v = qs[key];
if (v.multiValue) {
for (var i = 0; i < v.multiValue.length; i++) {
parts.push(key + "=" + v.multiValue[i].value);
}
} else {
parts.push(v.value === "" ? key : key + "=" + v.value);
}
}
return parts.length ? "?" + parts.join("&") : "";
}
Loading
Loading