daiso 1.2.5 → 1.2.6

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
@@ -85,11 +85,11 @@
85
85
  **[실시간 서비스 상태 보기](https://aka-page.betteruptime.com/)**
86
86
 
87
87
  <!-- WORKERS_INVOCATIONS_CHART:START -->
88
- <h3>Cloudflare 요청 수 (2026-08-29 ~ 2026-09-27, 30일)</h3>
88
+ <h3>Cloudflare 요청 수 (2026-08-30 ~ 2026-09-28, 30일)</h3>
89
89
 
90
- <img src="./assets/analytics/workers-invocations.png?v=2026-09-27T19:00:02.076Z" alt="Cloudflare 요청 수 그래프 (2026-08-29 ~ 2026-09-27)" width="100%">
90
+ <img src="./assets/analytics/workers-invocations.png?v=2026-09-28T21:15:27.313Z" alt="Cloudflare 요청 수 그래프 (2026-08-30 ~ 2026-09-28)" width="100%">
91
91
 
92
- <sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-09-28 04:00 KST</sub>
92
+ <sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-09-29 06:15 KST</sub>
93
93
  <br><sub>집계: Worker 실행 + 루트 GET 리디렉션 요청 · 사용자 수와 다릅니다.</sub>
94
94
 
95
95
  </div>
@@ -1,22 +1,23 @@
1
1
  /**
2
2
  * 올리브영 GET API 라우트 등록
3
3
  */
4
+ import { oliveyoungCacheKey } from '../../utils/oliveyoungCacheKey.js';
4
5
  import { withEdgeCache } from '../../utils/cache.js';
5
6
  import { handleOliveyoungFindStores, handleOliveyoungCheckInventory, handleOliveyoungSearchProducts, } from '../handlers.js';
6
7
  export function registerOliveyoungRoutes(app) {
7
- app.get('/api/oliveyoung/products', async (c) => withEdgeCache(c.req.url, {
8
+ app.get('/api/oliveyoung/products', async (c) => withEdgeCache(oliveyoungCacheKey(c.req.url), {
8
9
  ttlSeconds: 60 * 5,
9
10
  staleWhileRevalidateSeconds: 60,
10
- keyPrefix: 'oliveyoung-products-v1',
11
+ keyPrefix: 'oliveyoung-products-v2',
11
12
  }, () => handleOliveyoungSearchProducts(c)));
12
- app.get('/api/oliveyoung/stores', async (c) => withEdgeCache(c.req.url, {
13
+ app.get('/api/oliveyoung/stores', async (c) => withEdgeCache(oliveyoungCacheKey(c.req.url), {
13
14
  ttlSeconds: 60 * 60 * 24,
14
15
  staleWhileRevalidateSeconds: 60 * 5,
15
- keyPrefix: 'oliveyoung-stores-v1',
16
+ keyPrefix: 'oliveyoung-stores-v2',
16
17
  }, () => handleOliveyoungFindStores(c)));
17
- app.get('/api/oliveyoung/inventory', async (c) => withEdgeCache(c.req.url, {
18
+ app.get('/api/oliveyoung/inventory', async (c) => withEdgeCache(oliveyoungCacheKey(c.req.url), {
18
19
  ttlSeconds: 60 * 10,
19
20
  staleWhileRevalidateSeconds: 60,
20
- keyPrefix: 'oliveyoung-inventory-v1',
21
+ keyPrefix: 'oliveyoung-inventory-v2',
21
22
  }, () => handleOliveyoungCheckInventory(c)));
22
23
  }
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * 서비스 프로바이더를 등록하고 MCP 서버에 도구를 연결합니다.
5
5
  */
6
+ import { diagnosticEvent } from '../utils/diagnostics.js';
6
7
  import { getErrorMessage, ServiceError, toServiceErrorDiagnostics } from './errors.js';
7
8
  import { createToolOutputSchema } from './outputSchema.js';
8
9
  function parseJsonText(text) {
@@ -172,11 +173,13 @@ export class ServiceRegistry {
172
173
  outputSchema: createToolOutputSchema(tool.name, tool.metadata.outputSchema),
173
174
  };
174
175
  server.registerTool(tool.name, metadata, async (args) => {
176
+ const started = Date.now();
175
177
  let result;
176
178
  try {
177
179
  result = await tool.handler(args);
178
180
  }
179
181
  catch (error) {
182
+ diagnosticEvent({ stage: 'tool', operation: tool.name, outcome: 'error', durationMs: Date.now() - started });
180
183
  const message = error instanceof ServiceError
181
184
  ? JSON.stringify({
182
185
  error: { code: error.code, message: error.message },
@@ -188,6 +191,7 @@ export class ServiceRegistry {
188
191
  content: [{ type: 'text', text: message }],
189
192
  };
190
193
  }
194
+ diagnosticEvent({ stage: 'tool', operation: tool.name, outcome: result.isError ? 'error' : 'ok', durationMs: Date.now() - started });
191
195
  const content = result.content.map((item) => ({
192
196
  type: item.type,
193
197
  text: item.text,
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * Cloudflare Workers에서 실행되는 플러그인 기반 MCP 서버입니다.
5
5
  * 다이소, 편의점, 백화점 등 다양한 서비스를 확장할 수 있습니다.
6
6
  */
7
+ import { diagnosticsMiddleware } from './middleware/diagnostics.js';
7
8
  import { dtryxTransportFromBindings } from './services/dtryx/transport.js';
8
9
  import { Hono } from 'hono';
9
10
  import { cors } from 'hono/cors';
@@ -245,6 +246,7 @@ const handleRootMcpRequest = async (c) => {
245
246
  };
246
247
  // Hono 앱 생성
247
248
  const app = new Hono();
249
+ app.use('*', diagnosticsMiddleware);
248
250
  // CORS 설정
249
251
  app.use('*', cors({
250
252
  origin: '*',
@@ -257,7 +259,7 @@ app.use('*', cors({
257
259
  'Last-Event-ID',
258
260
  'mcp-protocol-version',
259
261
  ],
260
- exposeHeaders: ['mcp-session-id', 'mcp-protocol-version'],
262
+ exposeHeaders: ['mcp-session-id', 'mcp-protocol-version', 'x-request-id'],
261
263
  }));
262
264
  // Zyte 연동 공개 GET API의 일일 호출량을 IP별로 제한합니다.
263
265
  app.use('/api/*', createDailyRateLimitMiddleware());
@@ -0,0 +1,17 @@
1
+ import { withDiagnostics, diagnosticEvent, diagnosticHeaders, diagnosticOperation, } from '../utils/diagnostics.js';
2
+ export const diagnosticsMiddleware = async (c, next) => withDiagnostics(async () => {
3
+ const start = performance.now();
4
+ try {
5
+ await next();
6
+ }
7
+ finally {
8
+ c.header('x-request-id', diagnosticHeaders()['x-request-id']);
9
+ diagnosticEvent({
10
+ stage: 'request',
11
+ operation: diagnosticOperation(c.req.url),
12
+ outcome: c.error || c.res.status >= 400 ? 'error' : 'ok',
13
+ status: c.res.status,
14
+ durationMs: performance.now() - start,
15
+ });
16
+ }
17
+ });
@@ -1,3 +1,4 @@
1
+ import { diagnosticHeaders } from '../../utils/diagnostics.js';
1
2
  /** 디트릭스 전용 중계 설정과 인증 전송입니다. */
2
3
  import { ServiceError } from '../../core/errors.js';
3
4
  import { createTimeoutController } from '../../utils/http.js';
@@ -38,6 +39,7 @@ export async function requestDtryxRelay(path, body, options, timeout) {
38
39
  throw new ServiceError('DTRYX_RELAY_CONFIG_ERROR', '디트릭스 릴레이 URL, 토큰 및 Access 설정을 확인하세요.', 503, false);
39
40
  }
40
41
  const headers = {
42
+ ...diagnosticHeaders(),
41
43
  'Content-Type': 'application/json',
42
44
  Accept: 'application/json',
43
45
  Authorization: `Bearer ${relayToken}`,
@@ -1,3 +1,4 @@
1
+ import { diagnosticHeaders } from '../../utils/diagnostics.js';
1
2
  /** 올리브영 무료 직접 요청 및 운영자가 설정한 브라우저 릴레이 전송. */
2
3
  import { fetchJson } from '../../utils/http.js';
3
4
  import { toOliveyoungRelayError } from './errors.js';
@@ -35,6 +36,7 @@ export async function requestOliveyoung(path, body, options = {}) {
35
36
  if (!relayToken?.trim())
36
37
  throw new Error('OY_RELAY_TOKEN이 필요합니다.');
37
38
  url = `${relayUrl.replace(/\/$/, '')}/v1/oliveyoung/${path.split('/').pop()}`;
39
+ Object.assign(headers, diagnosticHeaders());
38
40
  headers.Authorization = `Bearer ${relayToken}`;
39
41
  const { accessClientId, accessClientSecret } = options;
40
42
  if (accessClientId !== undefined || accessClientSecret !== undefined) {
@@ -0,0 +1,76 @@
1
+ /** 비밀값과 원문 없이 요청 경계를 연결하는 진단 컨텍스트입니다. */
2
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
+ const context = new AsyncLocalStorage();
4
+ export function withDiagnostics(work) {
5
+ if (context.getStore())
6
+ return work();
7
+ return context.run({ requestId: crypto.randomUUID(), sampled: Math.random() < 0.01, events: 0 }, work);
8
+ }
9
+ export function diagnosticHeaders() {
10
+ const current = context.getStore();
11
+ return current ? { 'x-request-id': current.requestId } : {};
12
+ }
13
+ export function diagnosticEvent(event) {
14
+ const current = context.getStore();
15
+ // 요청당 상한으로 재시도/팬아웃 로그가 무한히 늘어나지 않게 합니다.
16
+ if (!current || (event.outcome === 'ok' && !current.sampled) || current.events >= 32)
17
+ return;
18
+ current.events++;
19
+ console.error(JSON.stringify({
20
+ event: 'diagnostic',
21
+ time: new Date().toISOString(),
22
+ requestId: current.requestId,
23
+ stage: event.stage,
24
+ outcome: event.outcome,
25
+ operation: event.operation,
26
+ status: event.status,
27
+ durationMs: event.durationMs,
28
+ }));
29
+ }
30
+ const services = new Set([
31
+ 'daiso',
32
+ 'oliveyoung',
33
+ 'dtryx',
34
+ 'gs25',
35
+ 'cu',
36
+ 'seveneleven',
37
+ 'emart24',
38
+ 'lottemart',
39
+ 'cgv',
40
+ 'megabox',
41
+ 'lottecinema',
42
+ 'opinet',
43
+ 'places',
44
+ 'compare',
45
+ 'feedback',
46
+ 'health',
47
+ 'rate-limit',
48
+ 'actions',
49
+ ]);
50
+ const operations = new Set([
51
+ 'products',
52
+ 'stores',
53
+ 'inventory',
54
+ 'movies',
55
+ 'theaters',
56
+ 'timetable',
57
+ 'play-dates',
58
+ 'seats',
59
+ 'remaining-seats',
60
+ 'average',
61
+ 'lowest',
62
+ 'requests',
63
+ 'checks',
64
+ 'stats',
65
+ 'query',
66
+ 'popwords',
67
+ 'price',
68
+ ]);
69
+ export function diagnosticOperation(url) {
70
+ const parts = new URL(url).pathname.split('/');
71
+ if (parts[1] === 'mcp')
72
+ return 'mcp';
73
+ if (parts[1] === 'api' && services.has(parts[2]) && operations.has(parts[3]))
74
+ return `${parts[2]}.${parts[3]}`;
75
+ return 'other';
76
+ }
@@ -1,6 +1,4 @@
1
- /**
2
- * 공용 HTTP 유틸리티
3
- */
1
+ import { diagnosticEvent } from './diagnostics.js';
4
2
  export class HttpError extends Error {
5
3
  status;
6
4
  statusText;
@@ -73,11 +71,15 @@ async function requestWithTimeout(url, options, readResponse) {
73
71
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
74
72
  const { controller, timeoutId } = createTimeoutController(timeout);
75
73
  let readingResponse = false;
74
+ const started = Date.now();
75
+ let responseStatus;
76
76
  try {
77
77
  const response = await fetch(url, {
78
78
  ...restOptions,
79
79
  signal: controller.signal,
80
80
  });
81
+ responseStatus = response.status;
82
+ diagnosticEvent({ stage: 'http', outcome: response.ok ? 'ok' : 'error', status: response.status, durationMs: Date.now() - started });
81
83
  if (retryAllowed &&
82
84
  attempt < maxAttempts &&
83
85
  isRetryableStatus(response.status, retryStatusCodes)) {
@@ -99,6 +101,7 @@ async function requestWithTimeout(url, options, readResponse) {
99
101
  return await readResponse(response);
100
102
  }
101
103
  catch (error) {
104
+ diagnosticEvent({ stage: 'http', outcome: 'error', status: responseStatus, durationMs: Date.now() - started });
102
105
  if (readingResponse || !retryAllowed || attempt >= maxAttempts) {
103
106
  throw error;
104
107
  }
@@ -0,0 +1,28 @@
1
+ /** 핸들러가 읽는 query만 캐시 키에 포함하고 동일한 기본값을 합칩니다. */
2
+ const product = { keyword: '', page: '1', size: '20', sort: '01', includeSoldOut: 'false' };
3
+ const location = { lat: '37.5665', lng: '126.978' };
4
+ const defaults = {
5
+ '/api/oliveyoung/products': product,
6
+ '/api/oliveyoung/stores': { keyword: '', ...location, pageIdx: '1', limit: '20' },
7
+ '/api/oliveyoung/inventory': {
8
+ ...product,
9
+ ...location,
10
+ storeKeyword: '',
11
+ storeLimit: '10',
12
+ stockCheckLimit: '5',
13
+ },
14
+ };
15
+ export function oliveyoungCacheKey(input) {
16
+ const url = new URL(input);
17
+ const values = defaults[url.pathname];
18
+ if (!values)
19
+ return input;
20
+ const key = new URL(url.origin + url.pathname);
21
+ for (const [name, fallback] of Object.entries({ ...values, timeoutMs: '15000' })) {
22
+ key.searchParams.set(name, url.searchParams.get(name) || fallback);
23
+ }
24
+ const fresh = url.searchParams.get('_healthCheck');
25
+ if (fresh !== null)
26
+ key.searchParams.set('_healthCheck', fresh);
27
+ return key.toString();
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "daiso",
3
- "version": "1.2.5",
3
+ "version": "1.2.6",
4
4
  "description": "Korean retail and cinema MCP server, CLI, and Codex Skill for Daiso, convenience stores, marts, Olive Young, and theaters",
5
5
  "main": "dist/index.js",
6
6
  "bin": {