@zalkera/client 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,43 +7,6 @@
7
7
  - 동작: 응답 껍데기(`ApiResponse<T>`)를 벗겨 `data`만 반환, 실패는 `ZalkeraError`로 throw.
8
8
  - 전제: **서버 사이드 전용**(RSC·route handler·server action). 브라우저 직접 호출 미지원 ([서버 사이드 전용](#서버-사이드-전용-중요) 참고).
9
9
 
10
- ## `@oneque/client` 에서 옮겨오기
11
-
12
- 패키지 이름과 공개 심볼이 함께 바뀌었다(브랜드 개명). 옛 패키지는 발행 상태로 남지만 더는 갱신되지 않는다.
13
-
14
- ```diff
15
- - "@oneque/client": "^0.6.0"
16
- + "@zalkera/client": "^0.6.1"
17
- ```
18
-
19
- ```diff
20
- - import {createOnequeClient, OnequeError} from "@oneque/client";
21
- + import {createZalkeraClient, ZalkeraError} from "@zalkera/client";
22
- ```
23
-
24
- | 옛 이름 | 새 이름 |
25
- |---|---|
26
- | `createOnequeClient` | `createZalkeraClient` |
27
- | `OnequeError` | `ZalkeraError` |
28
- | `OnequeClient` | `ZalkeraClient` |
29
- | `OnequeClientOptions` | `ZalkeraClientOptions` |
30
-
31
- **환경변수 이름도 `ZALKERA_*` 로 바뀌었다**(2026-07-26 컷오버). 관리형 서빙(잘커라가 띄우는 사이트)은
32
- 인프라가 새 이름으로 주입하므로 할 일이 없다. **자기 인프라로 돌리는 BYO 사이트는 `.env` 의 이름을 직접
33
- 바꿔야 한다** — 구 이름 폴백은 제거됐다:
34
-
35
- | 옛 이름 | 새 이름 |
36
- |---|---|
37
- | `ONEQUE_API_BASE` | `ZALKERA_API_BASE` |
38
- | `ONEQUE_TENANT` | `ZALKERA_TENANT` |
39
- | `ONEQUE_STOREFRONT_KEY` | `ZALKERA_STOREFRONT_KEY` |
40
- | `ONEQUE_SITE_URL` | `ZALKERA_SITE_URL` |
41
-
42
- 동적 SSR 정당화 마커도 `zalkera-allow-dynamic` 이 새 이름이지만 기존 `oneque-`/`oneq-` 마커를 계속 수용한다.
43
- `x-oneque-revalidate-secret` **헤더 이름은 그대로다** — 백엔드가 보내는 와이어 이름이라 별도 전환이 필요하다.
44
-
45
- 기능·API 동작은 동일하다 — 이름만 바뀌었다.
46
-
47
10
  ## 설치
48
11
 
49
12
  ```bash
@@ -108,6 +71,7 @@ await zalkera.submitInquiry({name, email, subject, message}, {clientIp});
108
71
  | `getPost(slug)` | `GET /api/public/posts/{slug}` | `PostDetail` |
109
72
  | `recordPostView(slug, ctx?)` | `POST /api/public/posts/{slug}/view` | `boolean` |
110
73
  | `getPage(slug, options?)` | `GET /api/public/pages/{slug}` | `PageContent` |
74
+ | `listPages(params?, options?)` | `GET /api/public/pages` | `Paginated<PageSummary>` |
111
75
  | `listMenus(options?)` | `GET /api/public/menus` | `Menu[]` |
112
76
  | `getMediaUrl(id)` | `GET /api/public/media/{id}/url` | `MediaUrl` |
113
77
  | `submitInquiry(input, ctx?)` | `POST /api/public/inquiries` | `InquiryCreated` |
@@ -124,6 +88,7 @@ await zalkera.submitInquiry({name, email, subject, message}, {clientIp});
124
88
  | `getProductReviewSummary(productId, options?)` | `GET /api/public/products/{id}/reviews/summary` | `RatingSummary` |
125
89
  | `createProductReview(productId, input, accessToken)` | `POST /api/shop/products/{id}/reviews` | `Review` |
126
90
 
91
+ - `listPages`는 **열거 전용**(본문 없음 — sitemap용). `size` 상한 100.
127
92
  - 후기 조회는 **숫자 `productId`**(slug 아님) — `getProduct(slug).id`로 획득.
128
93
  - 후기 작성은 로그인 필수 + 구매검증(본인·배송완료 이상·상품 일치).
129
94
 
@@ -246,6 +211,20 @@ try {
246
211
  }
247
212
  ```
248
213
 
214
+ ### SDK 가 직접 만드는 에러 코드
215
+
216
+ 백엔드 `ErrorCode`가 아니라 클라이언트가 붙이는 코드다. 전부 `status === 502`이고 **배선·상류 문제**라 재시도로 풀리지 않는다.
217
+
218
+ | `code` | 뜻 / 처방 |
219
+ |---|---|
220
+ | `UPSTREAM_REDIRECT` | 백엔드가 3xx로 응답했다. **클라이언트는 리다이렉트를 따라가지 않는다** — 따라가면 `X-Storefront-Key`·`X-Tenant`·`X-Cart-Session`이 제3자 오리진에 그대로 전달되기 때문이다. `baseUrl`이 **최종 오리진**인지 확인하라(http↔https 승격·www 유무·프록시 경로 리라이트). 우회 옵션은 없다(보안 불변식). |
221
+ | `UPSTREAM_NON_JSON` | 2xx인데 본문이 envelope가 아니다(게이트웨이 HTML 등). |
222
+ | `UPSTREAM_UNAVAILABLE` | 502·503·504에 비JSON 본문. 상류 장애. |
223
+
224
+ ### 경로 파라미터
225
+
226
+ - slug·주문번호·id 등 **경로 파라미터는 라이브러리가 인코딩한다**. 넘기기 전에 `encodeURIComponent`를 직접 씌우지 마라 — 이중 인코딩된다.
227
+
249
228
  ### 시크릿 키 관련 에러
250
229
 
251
230
  - `401 STOREFRONT_KEY_REQUIRED` — 백엔드 `required` 모드인데 키 없음/무효. `secretKey` 설정 필요.
package/dist/index.cjs CHANGED
@@ -62,13 +62,18 @@ var ZalkeraError = class _ZalkeraError extends Error {
62
62
  }
63
63
  };
64
64
  var STOREFRONT_KEY_MESSAGES = {
65
- STOREFRONT_KEY_REQUIRED: "\uC2A4\uD1A0\uC5B4\uD504\uB860\uD2B8 \uC2DC\uD06C\uB9BF \uD0A4\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4. createZalkeraClient \uC758 secretKey \uC635\uC158\uC744 \uC124\uC815\uD558\uC138\uC694 (\uD30C\uD2B8\uB108 \uCF58\uC194\uC5D0\uC11C \uBC1C\uAE09 \u2192 \uC11C\uBC84 .env \uC758 ONEQUE_STOREFRONT_KEY). \uBE0C\uB77C\uC6B0\uC800 \uBC88\uB4E4\uC5D0 \uB123\uC9C0 \uB9C8\uC138\uC694.",
65
+ STOREFRONT_KEY_REQUIRED: "\uC2A4\uD1A0\uC5B4\uD504\uB860\uD2B8 \uC2DC\uD06C\uB9BF \uD0A4\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4. createZalkeraClient \uC758 secretKey \uC635\uC158\uC744 \uC124\uC815\uD558\uC138\uC694 (\uD30C\uD2B8\uB108 \uCF58\uC194\uC5D0\uC11C \uBC1C\uAE09 \u2192 \uC11C\uBC84 .env \uC758 ZALKERA_STOREFRONT_KEY). \uBE0C\uB77C\uC6B0\uC800 \uBC88\uB4E4\uC5D0 \uB123\uC9C0 \uB9C8\uC138\uC694.",
66
66
  TENANT_MISMATCH: "secretKey \uAC00 tenant \uC635\uC158\uACFC \uB2E4\uB978 \uD14C\uB10C\uD2B8\uC758 \uD0A4\uC785\uB2C8\uB2E4. \uB450 \uAC12\uC774 \uAC19\uC740 \uD14C\uB10C\uD2B8\uC778\uC9C0 \uD655\uC778\uD558\uC138\uC694."
67
67
  };
68
68
  function isApiErrorBody(value) {
69
69
  return typeof value === "object" && value !== null && "message" in value && typeof value.message === "string";
70
70
  }
71
71
 
72
+ // src/pathSegment.ts
73
+ function seg(v) {
74
+ return encodeURIComponent(String(v));
75
+ }
76
+
72
77
  // src/client.ts
73
78
  var DEFAULT_TIMEOUT_MS = 1e4;
74
79
  function createZalkeraClient(options) {
@@ -81,6 +86,9 @@ function createZalkeraClient(options) {
81
86
  }
82
87
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
83
88
  async function request(path, init) {
89
+ if (/(^|\/)\.{1,2}(\/|$)/.test(path)) {
90
+ throw new ZalkeraError(`\uACBD\uB85C\uC5D0 \uC0C1\uB300 \uC138\uADF8\uBA3C\uD2B8\uAC00 \uC788\uC2B5\uB2C8\uB2E4: ${path}`, { status: 400, code: "INVALID_PATH" });
91
+ }
84
92
  const url = new URL(`${baseUrl}/api${path}`);
85
93
  if (init?.query) {
86
94
  for (const [key, value] of Object.entries(init.query)) {
@@ -106,7 +114,14 @@ function createZalkeraClient(options) {
106
114
  method: init?.method ?? "GET",
107
115
  headers,
108
116
  body: init?.body != null ? JSON.stringify(init.body) : void 0,
109
- signal: controller.signal
117
+ signal: controller.signal,
118
+ // 리다이렉트를 따라가지 않는다 — **보안 불변식이라 스위치를 두지 않는다**(옵션으로 열면
119
+ // 그 옵션이 곧 사고 경로가 된다). 기본값 `follow` 로 크로스오리진 3xx 를 따라가면
120
+ // `X-Storefront-Key`(테넌트 시크릿)·`X-Tenant`·`X-Cart-Session`·`Idempotency-Key` 가
121
+ // 제3자 오리진에 그대로 전달된다 — undici 는 커스텀 헤더를 벗기지 않는다(실측).
122
+ // `"error"` 를 쓰지 않는 이유: undici 에서 일반 `TypeError: fetch failed` 로 떨어져
123
+ // 아래 네트워크 분기에 흡수되고, 그러면 원인(대개 baseUrl 오배선)이 사라진다.
124
+ redirect: "manual"
110
125
  };
111
126
  if (init?.next) fetchInit.next = init.next;
112
127
  response = await fetchImpl(url.toString(), fetchInit);
@@ -119,6 +134,14 @@ function createZalkeraClient(options) {
119
134
  } finally {
120
135
  clearTimeout(timer);
121
136
  }
137
+ if (isRedirectResponse(response)) {
138
+ response.body?.cancel().catch(() => {
139
+ });
140
+ throw new ZalkeraError(
141
+ `\uBC31\uC5D4\uB4DC\uAC00 \uB9AC\uB2E4\uC774\uB809\uD2B8\uB85C \uC751\uB2F5\uD588\uC2B5\uB2C8\uB2E4 (HTTP ${response.status}). \uBCF4\uC548\uC0C1 \uB530\uB77C\uAC00\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4 \u2014 \uB530\uB77C\uAC00\uBA74 \uC694\uCCAD \uD5E4\uB354(\uD14C\uB10C\uD2B8 \uC2DC\uD06C\uB9BF \uD0A4\xB7\uC7A5\uBC14\uAD6C\uB2C8 \uC138\uC158)\uAC00 \uB2E4\uB978 \uC624\uB9AC\uC9C4\uC73C\uB85C \uC0D9\uB2C8\uB2E4. baseUrl \uC774 \uCD5C\uC885 \uC624\uB9AC\uC9C4\uC778\uC9C0 \uD655\uC778\uD558\uC138\uC694(http\u2194https \uC2B9\uACA9, www \uC720\uBB34, \uD504\uB85D\uC2DC\uC758 \uACBD\uB85C \uB9AC\uB77C\uC774\uD2B8).`,
142
+ { status: 502, code: "UPSTREAM_REDIRECT" }
143
+ );
144
+ }
122
145
  const text = await response.text();
123
146
  const parsed = text ? safeJsonParse(text) : null;
124
147
  if (!response.ok) {
@@ -144,18 +167,18 @@ function createZalkeraClient(options) {
144
167
  sort: params?.sort
145
168
  }
146
169
  }),
147
- getPost: (slug) => request(`/public/posts/${encodeURIComponent(slug)}`),
148
- recordPostView: (slug, context) => request(`/public/posts/${encodeURIComponent(slug)}/view`, {
170
+ getPost: (slug) => request(`/public/posts/${seg(slug)}`),
171
+ recordPostView: (slug, context) => request(`/public/posts/${seg(slug)}/view`, {
149
172
  method: "POST",
150
173
  context
151
174
  }),
152
- getPage: (slug, options2) => request(`/public/pages/${encodeURIComponent(slug)}`, nextInit(options2)),
175
+ getPage: (slug, options2) => request(`/public/pages/${seg(slug)}`, nextInit(options2)),
153
176
  listPages: (params, options2) => request("/public/pages", {
154
177
  query: { page: params?.page, size: params?.size },
155
178
  ...nextInit(options2)
156
179
  }),
157
180
  listMenus: (options2) => request("/public/menus", { next: options2?.tags ? { tags: options2.tags } : void 0 }),
158
- getMediaUrl: (id) => request(`/public/media/${id}/url`),
181
+ getMediaUrl: (id) => request(`/public/media/${seg(id)}/url`),
159
182
  submitInquiry: (input, context) => request("/public/inquiries", {
160
183
  method: "POST",
161
184
  body: input,
@@ -167,7 +190,7 @@ function createZalkeraClient(options) {
167
190
  context
168
191
  }),
169
192
  // ── 커머스: 카탈로그 ────────────────────────────────────
170
- getProduct: (slug, options2) => request(`/public/products/${encodeURIComponent(slug)}`, nextInit(options2)),
193
+ getProduct: (slug, options2) => request(`/public/products/${seg(slug)}`, nextInit(options2)),
171
194
  listProducts: (params, options2) => request("/public/products", {
172
195
  query: {
173
196
  productType: params?.productType,
@@ -180,14 +203,14 @@ function createZalkeraClient(options) {
180
203
  }),
181
204
  listProductCategories: (options2) => request("/public/product-categories", nextInit(options2)),
182
205
  // ── 커머스: 상품후기(공개) ──────────────────────────────
183
- listProductReviews: (productId, params, options2) => request(`/public/products/${productId}/reviews`, {
206
+ listProductReviews: (productId, params, options2) => request(`/public/products/${seg(productId)}/reviews`, {
184
207
  query: { page: params?.page, size: params?.size },
185
208
  next: options2?.tags ? { tags: options2.tags } : void 0
186
209
  }),
187
- getProductReviewSummary: (productId, options2) => request(`/public/products/${productId}/reviews/summary`, {
210
+ getProductReviewSummary: (productId, options2) => request(`/public/products/${seg(productId)}/reviews/summary`, {
188
211
  next: options2?.tags ? { tags: options2.tags } : void 0
189
212
  }),
190
- createProductReview: (productId, input, accessToken) => request(`/shop/products/${productId}/reviews`, { method: "POST", body: input, bearer: accessToken }),
213
+ createProductReview: (productId, input, accessToken) => request(`/shop/products/${seg(productId)}/reviews`, { method: "POST", body: input, bearer: accessToken }),
191
214
  // ── 커머스: 고객 인증 ───────────────────────────────────
192
215
  socialLogin: (input) => request("/shop/auth/social", { method: "POST", body: input }),
193
216
  refreshSession: (refreshToken) => request("/shop/auth/refresh", { method: "POST", body: { refreshToken } }),
@@ -202,11 +225,11 @@ function createZalkeraClient(options) {
202
225
  }),
203
226
  createBooking: (accessToken, input) => request("/shop/booking/bookings", { method: "POST", body: input, bearer: accessToken }),
204
227
  myBookings: (accessToken) => request("/shop/booking/bookings", { bearer: accessToken }),
205
- cancelBooking: (accessToken, bookingCode) => request(`/shop/booking/bookings/${encodeURIComponent(bookingCode)}/cancel`, {
228
+ cancelBooking: (accessToken, bookingCode) => request(`/shop/booking/bookings/${seg(bookingCode)}/cancel`, {
206
229
  method: "POST",
207
230
  bearer: accessToken
208
231
  }),
209
- rescheduleBooking: (accessToken, bookingCode, newSlotId) => request(`/shop/booking/bookings/${encodeURIComponent(bookingCode)}/reschedule`, {
232
+ rescheduleBooking: (accessToken, bookingCode, newSlotId) => request(`/shop/booking/bookings/${seg(bookingCode)}/reschedule`, {
210
233
  method: "POST",
211
234
  body: { newSlotId },
212
235
  bearer: accessToken
@@ -214,33 +237,33 @@ function createZalkeraClient(options) {
214
237
  // ── 커머스: 장바구니 ────────────────────────────────────
215
238
  getCart: (session) => request("/shop/cart", shopInit(session)),
216
239
  addToCart: (variantId, quantity, session) => request("/shop/cart/items", shopInit(session, { method: "POST", body: { variantId, quantity } })),
217
- updateCartItem: (variantId, quantity, session) => request(`/shop/cart/items/${variantId}`, shopInit(session, { method: "PATCH", body: { quantity } })),
218
- removeFromCart: (variantId, session) => request(`/shop/cart/items/${variantId}`, shopInit(session, { method: "DELETE" })),
240
+ updateCartItem: (variantId, quantity, session) => request(`/shop/cart/items/${seg(variantId)}`, shopInit(session, { method: "PATCH", body: { quantity } })),
241
+ removeFromCart: (variantId, session) => request(`/shop/cart/items/${seg(variantId)}`, shopInit(session, { method: "DELETE" })),
219
242
  clearCart: (session) => request("/shop/cart", shopInit(session, { method: "DELETE" })),
220
243
  // ── 커머스: 결제·주문·배송 ──────────────────────────────
221
244
  checkout: (input, session, idempotencyKey) => request("/shop/checkout", shopInit(session, { method: "POST", body: input, idempotencyKey })),
222
245
  confirmPayment: (orderNo, providerParams, access) => request(
223
- `/shop/orders/${encodeURIComponent(orderNo)}/payment/confirm`,
246
+ `/shop/orders/${seg(orderNo)}/payment/confirm`,
224
247
  accessInit(access, { method: "POST", body: providerParams })
225
248
  ),
226
249
  startPayment: (orderNo, access) => request(
227
- `/shop/orders/${encodeURIComponent(orderNo)}/payment/session`,
250
+ `/shop/orders/${seg(orderNo)}/payment/session`,
228
251
  accessInit(access, { method: "POST" })
229
252
  ),
230
- getOrder: (orderNo, access) => request(`/shop/orders/${encodeURIComponent(orderNo)}`, accessInit(access)),
253
+ getOrder: (orderNo, access) => request(`/shop/orders/${seg(orderNo)}`, accessInit(access)),
231
254
  listMyOrders: (accessToken, params) => request("/shop/orders", {
232
255
  bearer: accessToken,
233
256
  query: { page: params?.page, size: params?.size }
234
257
  }),
235
258
  cancelOrder: (orderNo, access) => request(
236
- `/shop/orders/${encodeURIComponent(orderNo)}/cancel`,
259
+ `/shop/orders/${seg(orderNo)}/cancel`,
237
260
  accessInit(access, { method: "POST" })
238
261
  ),
239
262
  completeOrder: (orderNo, access) => request(
240
- `/shop/orders/${encodeURIComponent(orderNo)}/complete`,
263
+ `/shop/orders/${seg(orderNo)}/complete`,
241
264
  accessInit(access, { method: "POST" })
242
265
  ),
243
- getShipment: (orderNo, access) => request(`/shop/orders/${encodeURIComponent(orderNo)}/shipment`, accessInit(access))
266
+ getShipment: (orderNo, access) => request(`/shop/orders/${seg(orderNo)}/shipment`, accessInit(access))
244
267
  };
245
268
  }
246
269
  function nextInit(options) {
@@ -256,6 +279,9 @@ function accessInit(access, extra) {
256
279
  query: access.phone ? { phone: access.phone } : void 0
257
280
  };
258
281
  }
282
+ function isRedirectResponse(response) {
283
+ return response.type === "opaqueredirect" || response.status >= 300 && response.status < 400;
284
+ }
259
285
  function safeJsonParse(text) {
260
286
  try {
261
287
  return JSON.parse(text);
@@ -327,7 +353,7 @@ function asId(value) {
327
353
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
328
354
  }
329
355
  function mediaSrc(assetId) {
330
- return `/media/${assetId}`;
356
+ return `/media/${seg(assetId)}`;
331
357
  }
332
358
 
333
359
  // src/theme.ts