From 8b869ecfb941f93c422375270b8622d815d84157 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 2 Nov 2026 11:20:47 +0900 Subject: [PATCH 01/10] =?UTF-8?q?test:=20=EB=8A=90=EB=A6=B0=20=EC=9B=B9=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=EC=9E=91=EC=97=85=EC=9D=98=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EA=B3=BC=20=EC=98=A4=EB=A5=98=20=EB=B3=B4=EC=A1=B4=20?= =?UTF-8?q?=ED=9A=8C=EA=B7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/pages/browseJobs.test.ts | 60 ++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/pages/browseJobs.test.ts diff --git a/tests/pages/browseJobs.test.ts b/tests/pages/browseJobs.test.ts new file mode 100644 index 00000000..cbdfc60b --- /dev/null +++ b/tests/pages/browseJobs.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as entry from '../../src/index.js'; + +type JobStub = { fetch(request: Request): Promise; alarm(): Promise }; +function fixture() { + const data = new Map(); + const storage = { + get: vi.fn(async (key: string) => data.get(key)), + put: vi.fn(async (key: string, value: unknown) => { data.set(key, value); }), + setAlarm: vi.fn(async () => {}), + deleteAll: vi.fn(async () => { data.clear(); }), + transaction: async (fn: (tx: typeof storage) => Promise): Promise => fn(storage), + }; + const Constructor = (entry as unknown as { BrowseReadJob: new (state: { storage: unknown }) => JobStub }).BrowseReadJob; + expect(typeof Constructor).toBe('function'); + const job = new Constructor({ storage }); + const get = () => job.fetch(new Request('https://job/')); + const start = () => job.fetch(new Request('https://job/start', { method: 'POST' })); + const complete = (value: unknown) => job.fetch(new Request('https://job/complete', { method: 'POST', body: JSON.stringify(value) })); + return { job, data, storage, get, start, complete }; +} + +describe('느린 읽기 작업의 저장 경계', () => { + it('접수는 결과가 아니며 완료된 원본 오류 상태와 본문을 그대로 보존한다', async () => { + const { get, start, complete } = fixture(); + expect((await get()).status).toBe(404); + expect((await start()).status).toBe(201); + expect((await get()).status).toBe(200); + expect(await (await get()).json()).toMatchObject({ status: 'pending' }); + expect((await complete({ status: 502, body: '

외부 오류

', contentType: 'text/html' })).status).toBe(200); + expect(await (await get()).json()).toMatchObject({ status: 'ready', response: { status: 502, body: '

외부 오류

' } }); + expect((await complete({ status: 200, body: 'replacement', contentType: 'text/html' })).status).toBe(409); + expect((await start()).status).toBe(409); + }); + it('일찍 실행된 알람은 완료를 지우지 않고 접수 시간 제한과 만료를 구분한다', async () => { + vi.useFakeTimers(); + try { + const { job, get, start, storage } = fixture(); + await start(); + await job.alarm(); + expect(await (await get()).json()).toMatchObject({ status: 'pending' }); + await vi.advanceTimersByTimeAsync(25001); + await job.alarm(); + expect(await (await get()).json()).toMatchObject({ status: 'ready', response: { status: 504 } }); + expect(storage.deleteAll).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(300000); + await job.alarm(); + expect((await get()).status).toBe(404); + } finally { vi.useRealTimers(); } + }); + it('잘못된 완료·과대한 본문·미생성 완료·지원하지 않는 요청을 거절한다', async () => { + const { job, complete, start } = fixture(); + expect((await complete({ status: 200, body: 'ok', contentType: 'text/html' })).status).toBe(409); + await start(); + for (const value of [null, {}, { status: 204, body: 'x', contentType: 'text/html' }, { status: 200, body: '가'.repeat(90000), contentType: 'text/html' }, { status: 200, body: 'x', contentType: 'text/html\r\nsecret' }]) + expect((await complete(value)).status).toBe(400); + expect((await job.fetch(new Request('https://job/complete', { method: 'POST', body: '{' }))).status).toBe(400); + expect((await job.fetch(new Request('https://job/unknown', { method: 'POST' }))).status).toBe(405); + }); +}); From a702c70cdd41d31449ec204a69727c3f6d9becf6 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 2 Nov 2026 11:22:30 +0900 Subject: [PATCH 02/10] =?UTF-8?q?test:=20=EC=9D=BD=EA=B8=B0=20=EC=A0=91?= =?UTF-8?q?=EC=88=98=EC=9D=98=20=EC=9B=90=EB=B3=B8=20=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=EA=B3=BC=20=EC=98=A4=EB=A5=98=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=20=ED=9A=8C=EA=B7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/pages/browseJobsFlow.test.ts | 79 ++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/pages/browseJobsFlow.test.ts diff --git a/tests/pages/browseJobsFlow.test.ts b/tests/pages/browseJobsFlow.test.ts new file mode 100644 index 00000000..8b53b6af --- /dev/null +++ b/tests/pages/browseJobsFlow.test.ts @@ -0,0 +1,79 @@ +import { Hono } from 'hono'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as entry from '../../src/index.js'; +import type { AppBindings } from '../../src/api/response.js'; +import { registerBrowseRoutes } from '../../src/pages/browse.js'; + +const id = '12345678-1234-4123-8123-123456789abc'; +function fixture() { + const values = new Map(); + const storage = { + get: async (key: string) => values.get(key), + put: async (key: string, value: unknown) => { values.set(key, value); }, + setAlarm: vi.fn(async () => {}), + deleteAll: async () => { values.clear(); }, + transaction: async (fn: (s: unknown) => Promise) => fn(storage), + }; + const Ctor = (entry as unknown as { BrowseReadJob: new (state: { storage: unknown }) => { fetch(r: Request): Promise } }).BrowseReadJob; + expect(typeof Ctor).toBe('function'); + const job = new Ctor({ storage }); + const pending: Promise[] = []; + const context = { waitUntil: (p: Promise) => { pending.push(p); } } as ExecutionContext; + const env = { BROWSE_READ_JOBS: { idFromName: () => id, get: () => job } } as unknown as AppBindings; + const app = new Hono<{ Bindings: AppBindings }>(); + let resolve!: (r: Response) => void; + const source = new Promise((r) => { resolve = r; }); + let calls = 0; + let signal: AbortSignal | undefined; + let address: string | undefined; + app.get('/api/daiso/products', (c) => { calls++; signal=c.req.raw.signal; address=c.req.header('cf-connecting-ip'); return source; }); + registerBrowseRoutes(app); + const get = (path: string, init?: RequestInit) => app.request(path, init, env, context); + return { app, env, context, values, pending, get, resolve, count: () => calls, signal: () => signal, address: () => address }; +} +afterEach(() => vi.useRealTimers()); + +describe('읽기 접수와 실제 결과 전달', () => { + it('느린 원본은 한 번만 실행하고 pending의 새 실제 링크에서 원본 오류를 보존한다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const controller = new AbortController(); + const request = f.get(`/browse/read/${id}/daiso/products/지갑`, { headers: { 'cf-connecting-ip': '192.0.2.1', authorization: 'do-not-store' }, signal: controller.signal }); + await vi.advanceTimersByTimeAsync(1001); + const response = await request; + const body = await response.text(); + expect(body).toContain('pending'); + expect(body).not.toContain('quantity'); + expect(f.address()).toBe('192.0.2.1'); + controller.abort(); + expect(f.signal()?.aborted).toBe(false); + const link = body.match(/href="(\/browse\/jobs\/[^" ]+)">결과 확인/)![1]; + const stillPending = await (await f.get(link)).text(); + expect(stillPending).toContain('pending'); + expect(stillPending.match(/href="([^" ]+)">결과 확인/)![1]).not.toBe(link); + f.resolve(new Response('
원본 요청 거절
', { status: 502, headers: { 'Content-Type': 'text/html', 'Set-Cookie': 'private=do-not-store', 'Retry-After': '12' } })); + await Promise.all(f.pending); + const result = await f.get(link); + expect(result.status).toBe(502); + expect(await result.text()).toContain('원본 요청 거절'); + expect(result.headers.get('set-cookie')).toBeNull(); + expect(result.headers.get('cache-control')).toBe('no-store'); + expect(JSON.stringify([...f.values])).not.toContain('do-not-store'); + expect(f.count()).toBe(1); + }); + it('빠른 원본은 기존 응답을 사용하고 작업을 생성하지 않는다', async () => { + const f = fixture(); + f.resolve(new Response('
상품 실제 결과
', { headers: { 'Content-Type': 'text/html' } })); + const response = await f.get(`/browse/read/${id}/daiso/products/지갑`); + expect(await response.text()).toContain('상품 실제 결과'); + expect(f.values.size).toBe(0); + expect(f.pending).toHaveLength(0); + }); + it('작업 조회의 잘못된 ID·메서드·만료를 구별하고 원본을 재호출하지 않는다', async () => { + const f = fixture(); + expect((await f.get('/browse/jobs/bad/bad')).status).toBe(400); + expect((await f.get(`/browse/jobs/${id}/${id}`, { method: 'POST' })).status).toBe(405); + expect((await f.get(`/browse/jobs/${id}/${id}`)).status).toBe(410); + expect(f.count()).toBe(0); + }); +}); From bfa2bd98d23268504ded537b9bcfda2aaa9e51d1 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 2 Nov 2026 11:26:27 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=EB=8A=90=EB=A6=B0=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=EC=97=90=20=EC=A0=91=EC=88=98=EC=99=80=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B2=B0=EA=B3=BC=20=EB=A7=81=ED=81=AC=20=EC=A0=9C?= =?UTF-8?q?=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-11-02-browse-jobs-design.md | 10 ++ src/api/response.ts | 20 ++- src/durableObjects/browseReadJob.ts | 92 ++++++++++++ src/index.ts | 41 +++-- src/pages/browseJobs.ts | 141 ++++++++++++++++++ src/pages/browseRead.ts | 73 +++++---- tests/pages/browseJobs.test.ts | 58 +++++-- tests/pages/browseJobsFlow.test.ts | 68 +++++++-- wrangler.toml | 8 + 9 files changed, 439 insertions(+), 72 deletions(-) create mode 100644 docs/superpowers/specs/2026-11-02-browse-jobs-design.md create mode 100644 src/durableObjects/browseReadJob.ts create mode 100644 src/pages/browseJobs.ts diff --git a/docs/superpowers/specs/2026-11-02-browse-jobs-design.md b/docs/superpowers/specs/2026-11-02-browse-jobs-design.md new file mode 100644 index 00000000..8c740f0a --- /dev/null +++ b/docs/superpowers/specs/2026-11-02-browse-jobs-design.md @@ -0,0 +1,10 @@ +# 느린 읽기의 접수·결과 전달 + +2026-11-02 사용자 승인과 별도 Worker 실험을 따른다. 실제 ChatGPT 새 임시 채팅2건에서5초 가상 작업의 접수→서로 다른 실제 결과 링크→ready 및 서버 requestId 일치를 확인했다. 같은 주소 재시도는 실패/과거 pending 재사용이 관측됐다. + +- 기존 /browse/read의 실제 API 호출을 한 번 실행한다.1초 안에 끝나면 기존 응답을 반환한다. 느린 경우에만 별도 읽기 작업을 만들고 pending HTML 및 새 실제 결과 확인 링크를 반환한다. +- 원본 요청의 권한·IP별 한도·캐시를 기존 API에서 적용한다. 후속 상태 확인은 저장 결과만 읽고 원본을 다시 호출하지 않는다. 공개 MCP10도구/JSON/CLI와 일반 /browse 경로를 유지한다. +- 원본 호출과 HTML 장식을 waitUntil로 유지하고 사용자 연결 취소와 분리한다. 작업의25초 제한·5분 TTL·256KiB 저장 상한을 둔다. 제한·외부 오류·미확인 수량을 정상 재고0으로 바꾸지 않는다. +- 요청 헤더·인증·쿠키·원본 조회 조건은 작업 저장소에 보관하지 않는다. 안전한 공개 응답 본문·상태·Content-Type만 저장한다. 작업 주소는 무작위 UUID이며 상태 응답마다 다른 실제 후속 링크를 제공한다. HTML 생성·작업 완료·원본 재고 갱신 시각은 구별한다. +- 저장 실패 시 기존 동기 응답으로 복귀한다. 늦은 완료/알람의 경합으로 정상 결과를 덮어쓰지 않는다. 대기·만료는 HTML로 명확히 보고한다. +- 저장 및 순차 실제 API 경계를 가상시계로 검증하고, 필수 CI/Coverage100%/리뷰/배포 이후 실제 공개 조회와 ChatGPT 웹에서 상태 링크를 확인한다. 검증용 브라우저는 소유 창1개, 검사와 겹치지 않는다. diff --git a/src/api/response.ts b/src/api/response.ts index 42074f10..b9feb3cd 100644 --- a/src/api/response.ts +++ b/src/api/response.ts @@ -3,11 +3,16 @@ */ import type { Context } from 'hono'; -import { ServiceError, toServiceErrorDiagnostics, toStandardErrorDiagnostics } from '../core/errors.js'; +import { + ServiceError, + toServiceErrorDiagnostics, + toStandardErrorDiagnostics, +} from '../core/errors.js'; export interface AppBindings { DAISO_JP_PRODUCT_RELAY_ENABLED?: string; DAILY_RATE_LIMITER?: DurableObjectNamespace; + BROWSE_READ_JOBS?: DurableObjectNamespace; UPSTREAM_ROUTE_HEALTH?: DurableObjectNamespace; GEOCODE_GATEWAY?: DurableObjectNamespace; ZYTE_API_KEY?: string; @@ -92,9 +97,12 @@ export function errorResponse( /** 원문이 제거된 외부 서비스 오류를 실제 상태와 함께 반환합니다. */ export function serviceErrorResponse(c: ApiContext, error: ServiceError, operation: string) { if (error.retryAfter !== undefined) c.header('Retry-After', String(error.retryAfter)); - return c.json>({ - success: false, - error: { code: error.code, message: error.message }, - diagnostics: toServiceErrorDiagnostics(error, operation), - }, error.status); + return c.json>( + { + success: false, + error: { code: error.code, message: error.message }, + diagnostics: toServiceErrorDiagnostics(error, operation), + }, + error.status, + ); } diff --git a/src/durableObjects/browseReadJob.ts b/src/durableObjects/browseReadJob.ts new file mode 100644 index 00000000..a75191b8 --- /dev/null +++ b/src/durableObjects/browseReadJob.ts @@ -0,0 +1,92 @@ +/** 읽기 결과만 잠시 저장합니다. 요청 헤더·인증값·원본 호출 조건은 저장하지 않습니다. */ +export const BROWSE_JOB_TTL = 300000; +export const BROWSE_JOB_DEADLINE = 25000; +export type ReadSnapshot = { status: number; body: string; contentType: string }; +type Job = { + status: 'pending' | 'ready'; + createdAt: number; + completedAt?: string; + response?: ReadSnapshot; +}; +export const expiredReadSnapshot = (): ReadSnapshot => ({ + status: 504, + contentType: 'text/html; charset=utf-8', + body: '

조회가 제한 시간 안에 완료되지 않았습니다.

수량은 미확인입니다. 접수는 재고 조회 성공이 아닙니다.

', +}); + +function validSnapshot(input: unknown): input is ReadSnapshot { + if (!input || typeof input !== 'object') return false; + const v = input as ReadSnapshot; + return ( + Number.isInteger(v.status) && + v.status >= 200 && + v.status <= 599 && + ![204, 205, 304].includes(v.status) && + typeof v.body === 'string' && + new TextEncoder().encode(v.body).length <= 262144 && + typeof v.contentType === 'string' && + v.contentType.length <= 128 && + !/[\r\n]/.test(v.contentType) + ); +} + +export class BrowseReadJob { + constructor(private readonly state: DurableObjectState) {} + async fetch(request: Request): Promise { + const path = new URL(request.url).pathname; + const storage = this.state.storage; + const current = await storage.get('job'); + if (request.method === 'GET') { + if (!current || Date.now() >= current.createdAt + BROWSE_JOB_TTL) + return new Response(null, { status: 404 }); + return Response.json(current); + } + if (request.method === 'POST' && path === '/start') { + if (current) return new Response(null, { status: 409 }); + await storage.put('job', { status: 'pending', createdAt: Date.now() }); + await storage.setAlarm(Date.now() + BROWSE_JOB_DEADLINE); + return new Response(null, { status: 201 }); + } + if (request.method !== 'POST' || path !== '/complete') + return new Response(null, { status: 405 }); + let input: unknown; + try { + input = JSON.parse(await request.text()); + } catch { + return new Response(null, { status: 400 }); + } + if (!validSnapshot(input)) return new Response(null, { status: 400 }); + const accepted = await storage.transaction(async (tx) => { + const job = await tx.get('job'); + if (!job || job.status !== 'pending' || Date.now() >= job.createdAt + BROWSE_JOB_TTL) + return false; + await tx.put('job', { + ...job, + status: 'ready', + completedAt: new Date().toISOString(), + response: input, + }); + return true; + }); + return new Response(null, { status: accepted ? 200 : 409 }); + } + async alarm(): Promise { + const job = await this.state.storage.get('job'); + if (!job || Date.now() >= job.createdAt + BROWSE_JOB_TTL) { + await this.state.storage.deleteAll(); + return; + } + if (job.status === 'pending' && Date.now() >= job.createdAt + BROWSE_JOB_DEADLINE) { + await this.state.storage.put('job', { + ...job, + status: 'ready', + completedAt: new Date().toISOString(), + response: expiredReadSnapshot(), + }); + } + await this.state.storage.setAlarm( + job.createdAt + + (Date.now() < job.createdAt + BROWSE_JOB_DEADLINE ? BROWSE_JOB_DEADLINE : BROWSE_JOB_TTL), + ); + } +} diff --git a/src/index.ts b/src/index.ts index a48df9e8..aaf836ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export { BrowseReadJob } from './durableObjects/browseReadJob.js'; import { registerBrowseRoutes } from './pages/browse.js'; import { applyCompactTools } from './core/compact/server.js'; import { COMPACT_CATALOG, PUBLIC_TOOL_NAMES } from './core/compact/catalog.js'; @@ -81,12 +82,17 @@ const createRegistry = (bindings?: AppBindings) => { createDaisoService, () => createDaisoJpService(japanProductTransportFromBindings(bindings)), () => - createGs25Service({ ...convenienceTransportFromBindings(bindings), + createGs25Service({ + ...convenienceTransportFromBindings(bindings), ...geocodeBindings(bindings), zyteApiKey: bindings?.ZYTE_API_KEY, apiKey: bindings?.GS25_API_KEY, }), - () => createSevenElevenService({ ...convenienceTransportFromBindings(bindings), zyteApiKey: bindings?.ZYTE_API_KEY }), + () => + createSevenElevenService({ + ...convenienceTransportFromBindings(bindings), + zyteApiKey: bindings?.ZYTE_API_KEY, + }), () => createCompareService(convenienceTransportFromBindings(bindings)), () => createFeedbackService({ @@ -104,7 +110,8 @@ const createRegistry = (bindings?: AppBindings) => { ...geocodeBindings(bindings), }), () => - createCuService({ ...convenienceTransportFromBindings(bindings), + createCuService({ + ...convenienceTransportFromBindings(bindings), zyteApiKey: bindings?.ZYTE_API_KEY, ...geocodeBindings(bindings), }), @@ -149,7 +156,14 @@ const createMcpServer = (bindings?: AppBindings) => { export function buildRootInfo() { const registry = createRegistry(); - const services = registry.getServicesInfo().map(service => ({ ...service, tools: COMPACT_CATALOG.filter(group => group.routes.some(route => service.tools.includes(route.legacy))).map(group => group.name) })); + const services = registry + .getServicesInfo() + .map((service) => ({ + ...service, + tools: COMPACT_CATALOG.filter((group) => + group.routes.some((route) => service.tools.includes(route.legacy)), + ).map((group) => group.name), + })); const allTools = [...PUBLIC_TOOL_NAMES]; return { @@ -179,7 +193,9 @@ export function buildRootInfo() { } const ROOT_INFO_JSON = JSON.stringify(buildRootInfo()); const ROOT_INFO_BODY = new TextEncoder().encode(ROOT_INFO_JSON); -const ROOT_HTML_BODY = new TextEncoder().encode(buildDiscoveryAssets(buildRootInfo())['discovery.html']); +const ROOT_HTML_BODY = new TextEncoder().encode( + buildDiscoveryAssets(buildRootInfo())['discovery.html'], +); const ROOT_INFO_HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': String(ROOT_INFO_BODY.byteLength), @@ -199,9 +215,13 @@ function createRootInfoResponse(request: Request): Response { const body = json ? ROOT_INFO_BODY : ROOT_HTML_BODY; return new Response(request.method === 'HEAD' ? null : body, { status: 200, - headers: { ...ROOT_INFO_HEADERS, + headers: { + ...ROOT_INFO_HEADERS, 'Content-Type': json ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8', - 'Content-Length': String(body.byteLength), 'Cache-Control': json ? ROOT_INFO_HEADERS['Cache-Control'] : 'no-store', Vary: 'Accept' }, + 'Content-Length': String(body.byteLength), + 'Cache-Control': json ? ROOT_INFO_HEADERS['Cache-Control'] : 'no-store', + Vary: 'Accept', + }, }); } /** @@ -366,7 +386,9 @@ registerServiceRoutes(app); registerBrowseRoutes(app); // 설치 없는 첫 조회 안내 -app.get('/guide/:version', (c) => createVersionedGuideResponse(new URL(c.req.url).origin, c.req.param('version'))); +app.get('/guide/:version', (c) => + createVersionedGuideResponse(new URL(c.req.url).origin, c.req.param('version')), +); app.get('/llms.txt', (c) => createAgentStartResponse(new URL(c.req.url).origin)); // 프롬프트 페이지 (MCP 미지원 에이전트용) @@ -402,7 +424,8 @@ app.get('/privacy', (c) => { // GET API 엔드포인트 (MCP 미지원 에이전트용) app.get('/api/actions/query', async (c) => { - if (['lottemartFindStores', 'lottemartSearchProducts'].includes(c.req.query('action') || '')) return handleLotteMartFindStores(c); + if (['lottemartFindStores', 'lottemartSearchProducts'].includes(c.req.query('action') || '')) + return handleLotteMartFindStores(c); try { const targetUrl = buildActionQueryTargetUrl(c.req.url); const headers = new Headers(c.req.raw.headers); diff --git a/src/pages/browseJobs.ts b/src/pages/browseJobs.ts new file mode 100644 index 00000000..68e60696 --- /dev/null +++ b/src/pages/browseJobs.ts @@ -0,0 +1,141 @@ +/** 느린 읽기만 접수형으로 전환하고 실제 원본 요청은 기존 API에서 한 번 실행합니다. */ +import type { Context, Hono } from 'hono'; +import type { AppBindings } from '../api/response.js'; +import { + BROWSE_JOB_DEADLINE, + expiredReadSnapshot, + type ReadSnapshot, +} from '../durableObjects/browseReadJob.js'; +type Bindings = { Bindings: AppBindings }; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +function page(body: string, status = 200): Response { + return new Response( + `DAISO MCP 읽기 작업${body}`, + { + status, + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + 'X-Robots-Tag': 'noindex, nofollow', + 'Content-Security-Policy': "default-src 'none'; base-uri 'none'; frame-ancestors 'none'", + }, + }, + ); +} +function pending(id: string): Response { + const path = `/browse/jobs/${id}/${crypto.randomUUID()}`; + return page( + `

조회 접수 · pending

아직 결과가 없습니다. 접수는 조회 성공이나 재고0을 뜻하지 않습니다.

각 응답의 새로운 실제 링크를 최대3번 선택하고 계속 pending이면 미확인으로 보고하세요. 이전 주소를 반복하지 마세요.

결과 확인

완성 주소: https://mcp.aka.page${path}

접근 거절 시 아래 문장을 그대로 복사해 이 대화에 보내주세요.

다음 주소를 웹 URL 열기 도구로 직접 열어 실제 결과를 확인해주세요: https://mcp.aka.page${path}

접수 ID: ${id} · HTML 생성 ${new Date().toISOString()}

`, + ); +} +export function registerBrowseJobRoutes(app: Hono): void { + app.all('/browse/jobs/:id/:nonce', async (c) => { + if (c.req.method !== 'GET') return page('

GET 조회만 지원합니다.

', 405); + const { id, nonce } = c.req.param(); + if (!UUID.test(id) || !UUID.test(nonce)) + return page('

읽기 작업 주소가 올바르지 않습니다.

', 400); + const namespace = c.env.BROWSE_READ_JOBS; + if (!namespace) return page('

읽기 작업 저장소를 사용할 수 없습니다.

', 503); + const response = await namespace + .get(namespace.idFromName(id)) + .fetch(new Request('https://job/')); + if (!response.ok) + return page( + '

읽기 작업이 없거나 만료됐습니다.

재고 수량은 미확인입니다. 원본을 자동 재조회하지 않습니다.

', + 410, + ); + const job = await response.json<{ + status: string; + response?: ReadSnapshot; + completedAt?: string; + }>(); + if (job.status === 'pending') return pending(id); + const result = job.response!; + return new Response(result.body, { + status: result.status, + headers: { + 'Content-Type': result.contentType, + 'Cache-Control': 'no-store', + 'X-Robots-Tag': 'noindex, nofollow', + 'X-Daiso-Result-At': job.completedAt!, + }, + }); + }); +} + +export async function withBrowseJob( + c: Context, + operation: Promise, + cancel: () => void, +): Promise { + const namespace = c.env.BROWSE_READ_JOBS; + if (!namespace) return operation; + let execution: { waitUntil(promise: Promise): void }; + try { + execution = c.executionCtx; + } catch { + return operation; + } + let timer: ReturnType | undefined; + const first = await Promise.race([ + operation, + new Promise((r) => { + timer = setTimeout(() => r(null), 1000); + }), + ]).finally(() => clearTimeout(timer)); + if (first) return first; + const id = crypto.randomUUID(); + const job = namespace.get(namespace.idFromName(id)); + try { + const started = await job.fetch(new Request('https://job/start', { method: 'POST' })); + if (!started.ok) return operation; + } catch { + return operation; + } + const finish = async () => { + let deadline: ReturnType | undefined; + try { + const response = await Promise.race([ + operation, + new Promise((r) => { + deadline = setTimeout(() => { + cancel(); + const v = expiredReadSnapshot(); + r( + new Response(v.body, { + status: v.status, + headers: { 'Content-Type': v.contentType }, + }), + ); + }, BROWSE_JOB_DEADLINE - 1000); + }), + ]); + const body = await response.text(); + let snapshot: ReadSnapshot = { + status: response.status, + contentType: response.headers.get('Content-Type') ?? 'text/html; charset=utf-8', + body, + }; + if (new TextEncoder().encode(body).length > 262144) + snapshot = { + status: 502, + contentType: 'text/html; charset=utf-8', + body: '

조회 결과가 저장 크기 제한을 초과했습니다.

수량 미확인. 결과를 잘라 정상으로 표시하지 않습니다.

', + }; + await job.fetch( + new Request('https://job/complete', { method: 'POST', body: JSON.stringify(snapshot) }), + ); + } catch { + const snapshot = expiredReadSnapshot(); + await job + .fetch( + new Request('https://job/complete', { method: 'POST', body: JSON.stringify(snapshot) }), + ) + .catch(() => {}); + } finally { + clearTimeout(deadline); + } + }; + execution.waitUntil(finish()); + return pending(id); +} diff --git a/src/pages/browseRead.ts b/src/pages/browseRead.ts index a3be7afb..5757afff 100644 --- a/src/pages/browseRead.ts +++ b/src/pages/browseRead.ts @@ -1,11 +1,13 @@ /** 웹 도구의 수집 주소를 구분하되 기존 API 캐시와 한도를 유지합니다. */ import type { Context, Hono } from 'hono'; +import { registerBrowseJobRoutes, withBrowseJob } from './browseJobs.js'; import type { AppBindings } from '../api/response.js'; type Bindings = { Bindings: AppBindings }; const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; export function registerBrowseReadRoutes(app: Hono): void { + registerBrowseJobRoutes(app); const forward = async (c: Context, id: string, suffix: string): Promise => { if (c.req.method !== 'GET') return c.text('GET 조회만 지원합니다.', 405); if (!ID.test(id) || suffix.split('/')[1] === 'read') @@ -19,39 +21,44 @@ export function registerBrowseReadRoutes(app: Hono): void { target.pathname = '/browse' + suffix; const requestHeaders = new Headers(c.req.raw.headers); requestHeaders.delete('host'); - const source = await app.fetch( - new Request(target, { method: 'GET', headers: requestHeaders, signal: c.req.raw.signal }), - c.env, - ); - const headers = new Headers(source.headers); - headers.set('Cache-Control', 'no-store'); - headers.set('X-Robots-Tag', 'noindex, nofollow'); - headers.set('X-Daiso-Read-Id', id); - const location = headers.get('Location'); - if (location === '/browse' || location?.startsWith('/browse/')) - headers.set('Location', scoped(location)); - if (!headers.get('Content-Type')?.includes('text/html')) - return new Response(source.body, { status: source.status, headers }); - let body = (await source.text()).replace( - /href="(\/browse(?:\/[^"]*)?)"/g, - (_, path: string) => `href="${scoped(path)}"`, - ); - const refresh = `/browse/read/${crypto.randomUUID()}${suffix}` - .replaceAll('&', '&') - .replaceAll('"', '"') - .replaceAll('<', '<') - .replaceAll('>', '>'); - const copyUrl = `https://mcp.aka.page${prefix}${suffix}` - .replaceAll('&', '&') - .replaceAll('"', '"') - .replaceAll('<', '<') - .replaceAll('>', '>'); - const recovery = `
웹 읽기 접근 거절 시 복사 재전송

아래 문장을 그대로 복사해 이 대화에 보내주세요. 접수나 문장 제시는 재고 조회 완료가 아닙니다.

다음 주소를 웹 URL 열기 도구로 직접 열어 실제 결과를 확인해주세요: ${copyUrl}. 실패하면 오류 원문을 알려주세요.

사용자가 재전송한 뒤 한 번 조회합니다. 단순 허용 문구나 URL 일부로 대체하지 마세요. TimeoutError는 같은 실제 링크를 한 번만 재확인하고 계속 실패하면 미확인으로 보고합니다.

`; - const note = `

읽기 세션: ${id} · HTML 응답 생성: ${new Date().toISOString()}

새 주소도 기존 API 캐시·한도·권한을 사용합니다. HTML 응답 생성 시각은 원본 재고 갱신 시각이 아닙니다.

같은 조건으로 새 응답 확인

${recovery}
`; - body = body.includes('') ? body.replace('', note + '') : body + note; - headers.delete('Content-Length'); - headers.delete('ETag'); - return new Response(body, { status: source.status, headers }); + const controller = new AbortController(); + const operation = Promise.resolve( + app.fetch( + new Request(target, { method: 'GET', headers: requestHeaders, signal: controller.signal }), + c.env, + ), + ).then(async (source) => { + const headers = new Headers(source.headers); + headers.set('Cache-Control', 'no-store'); + headers.set('X-Robots-Tag', 'noindex, nofollow'); + headers.set('X-Daiso-Read-Id', id); + const location = headers.get('Location'); + if (location === '/browse' || location?.startsWith('/browse/')) + headers.set('Location', scoped(location)); + if (!headers.get('Content-Type')?.includes('text/html')) + return new Response(source.body, { status: source.status, headers }); + let body = (await source.text()).replace( + /href="(\/browse(?:\/[^"]*)?)"/g, + (_, path: string) => `href="${scoped(path)}"`, + ); + const refresh = `/browse/read/${crypto.randomUUID()}${suffix}` + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); + const copyUrl = `https://mcp.aka.page${prefix}${suffix}` + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); + const recovery = `
웹 읽기 접근 거절 시 복사 재전송

아래 문장을 그대로 복사해 이 대화에 보내주세요. 접수나 문장 제시는 재고 조회 완료가 아닙니다.

다음 주소를 웹 URL 열기 도구로 직접 열어 실제 결과를 확인해주세요: ${copyUrl}. 실패하면 오류 원문을 알려주세요.

사용자가 재전송한 뒤 한 번 조회합니다. 단순 허용 문구나 URL 일부로 대체하지 마세요. TimeoutError는 같은 실제 링크를 한 번만 재확인하고 계속 실패하면 미확인으로 보고합니다.

`; + const note = `

읽기 세션: ${id} · HTML 응답 생성: ${new Date().toISOString()}

새 주소도 기존 API 캐시·한도·권한을 사용합니다. HTML 응답 생성 시각은 원본 재고 갱신 시각이 아닙니다.

같은 조건으로 새 응답 확인

${recovery}
`; + body = body.includes('') ? body.replace('', note + '') : body + note; + headers.delete('Content-Length'); + headers.delete('ETag'); + return new Response(body, { status: source.status, headers }); + }); + return withBrowseJob(c, operation, () => controller.abort()); }; app.all('/browse/read', (c) => forward(c, crypto.randomUUID(), '')); const existing = (c: Context) => { diff --git a/tests/pages/browseJobs.test.ts b/tests/pages/browseJobs.test.ts index cbdfc60b..56ab4987 100644 --- a/tests/pages/browseJobs.test.ts +++ b/tests/pages/browseJobs.test.ts @@ -6,17 +6,24 @@ function fixture() { const data = new Map(); const storage = { get: vi.fn(async (key: string) => data.get(key)), - put: vi.fn(async (key: string, value: unknown) => { data.set(key, value); }), + put: vi.fn(async (key: string, value: unknown) => { + data.set(key, value); + }), setAlarm: vi.fn(async () => {}), - deleteAll: vi.fn(async () => { data.clear(); }), + deleteAll: vi.fn(async () => { + data.clear(); + }), transaction: async (fn: (tx: typeof storage) => Promise): Promise => fn(storage), }; - const Constructor = (entry as unknown as { BrowseReadJob: new (state: { storage: unknown }) => JobStub }).BrowseReadJob; + const Constructor = ( + entry as unknown as { BrowseReadJob: new (state: { storage: unknown }) => JobStub } + ).BrowseReadJob; expect(typeof Constructor).toBe('function'); const job = new Constructor({ storage }); const get = () => job.fetch(new Request('https://job/')); const start = () => job.fetch(new Request('https://job/start', { method: 'POST' })); - const complete = (value: unknown) => job.fetch(new Request('https://job/complete', { method: 'POST', body: JSON.stringify(value) })); + const complete = (value: unknown) => + job.fetch(new Request('https://job/complete', { method: 'POST', body: JSON.stringify(value) })); return { job, data, storage, get, start, complete }; } @@ -27,9 +34,17 @@ describe('느린 읽기 작업의 저장 경계', () => { expect((await start()).status).toBe(201); expect((await get()).status).toBe(200); expect(await (await get()).json()).toMatchObject({ status: 'pending' }); - expect((await complete({ status: 502, body: '

외부 오류

', contentType: 'text/html' })).status).toBe(200); - expect(await (await get()).json()).toMatchObject({ status: 'ready', response: { status: 502, body: '

외부 오류

' } }); - expect((await complete({ status: 200, body: 'replacement', contentType: 'text/html' })).status).toBe(409); + expect( + (await complete({ status: 502, body: '

외부 오류

', contentType: 'text/html' })) + .status, + ).toBe(200); + expect(await (await get()).json()).toMatchObject({ + status: 'ready', + response: { status: 502, body: '

외부 오류

' }, + }); + expect( + (await complete({ status: 200, body: 'replacement', contentType: 'text/html' })).status, + ).toBe(409); expect((await start()).status).toBe(409); }); it('일찍 실행된 알람은 완료를 지우지 않고 접수 시간 제한과 만료를 구분한다', async () => { @@ -41,20 +56,37 @@ describe('느린 읽기 작업의 저장 경계', () => { expect(await (await get()).json()).toMatchObject({ status: 'pending' }); await vi.advanceTimersByTimeAsync(25001); await job.alarm(); - expect(await (await get()).json()).toMatchObject({ status: 'ready', response: { status: 504 } }); + expect(await (await get()).json()).toMatchObject({ + status: 'ready', + response: { status: 504 }, + }); expect(storage.deleteAll).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(300000); await job.alarm(); expect((await get()).status).toBe(404); - } finally { vi.useRealTimers(); } + } finally { + vi.useRealTimers(); + } }); it('잘못된 완료·과대한 본문·미생성 완료·지원하지 않는 요청을 거절한다', async () => { const { job, complete, start } = fixture(); - expect((await complete({ status: 200, body: 'ok', contentType: 'text/html' })).status).toBe(409); + expect((await complete({ status: 200, body: 'ok', contentType: 'text/html' })).status).toBe( + 409, + ); await start(); - for (const value of [null, {}, { status: 204, body: 'x', contentType: 'text/html' }, { status: 200, body: '가'.repeat(90000), contentType: 'text/html' }, { status: 200, body: 'x', contentType: 'text/html\r\nsecret' }]) + for (const value of [ + null, + {}, + { status: 204, body: 'x', contentType: 'text/html' }, + { status: 200, body: '가'.repeat(90000), contentType: 'text/html' }, + { status: 200, body: 'x', contentType: 'text/html\r\nsecret' }, + ]) expect((await complete(value)).status).toBe(400); - expect((await job.fetch(new Request('https://job/complete', { method: 'POST', body: '{' }))).status).toBe(400); - expect((await job.fetch(new Request('https://job/unknown', { method: 'POST' }))).status).toBe(405); + expect( + (await job.fetch(new Request('https://job/complete', { method: 'POST', body: '{' }))).status, + ).toBe(400); + expect((await job.fetch(new Request('https://job/unknown', { method: 'POST' }))).status).toBe( + 405, + ); }); }); diff --git a/tests/pages/browseJobsFlow.test.ts b/tests/pages/browseJobsFlow.test.ts index 8b53b6af..2be1054d 100644 --- a/tests/pages/browseJobsFlow.test.ts +++ b/tests/pages/browseJobsFlow.test.ts @@ -9,27 +9,59 @@ function fixture() { const values = new Map(); const storage = { get: async (key: string) => values.get(key), - put: async (key: string, value: unknown) => { values.set(key, value); }, + put: async (key: string, value: unknown) => { + values.set(key, value); + }, setAlarm: vi.fn(async () => {}), - deleteAll: async () => { values.clear(); }, + deleteAll: async () => { + values.clear(); + }, transaction: async (fn: (s: unknown) => Promise) => fn(storage), }; - const Ctor = (entry as unknown as { BrowseReadJob: new (state: { storage: unknown }) => { fetch(r: Request): Promise } }).BrowseReadJob; + const Ctor = ( + entry as unknown as { + BrowseReadJob: new (state: { storage: unknown }) => { fetch(r: Request): Promise }; + } + ).BrowseReadJob; expect(typeof Ctor).toBe('function'); const job = new Ctor({ storage }); const pending: Promise[] = []; - const context = { waitUntil: (p: Promise) => { pending.push(p); } } as ExecutionContext; - const env = { BROWSE_READ_JOBS: { idFromName: () => id, get: () => job } } as unknown as AppBindings; + const context = { + waitUntil: (p: Promise) => { + pending.push(p); + }, + } as ExecutionContext; + const env = { + BROWSE_READ_JOBS: { idFromName: () => id, get: () => job }, + } as unknown as AppBindings; const app = new Hono<{ Bindings: AppBindings }>(); let resolve!: (r: Response) => void; - const source = new Promise((r) => { resolve = r; }); + const source = new Promise((r) => { + resolve = r; + }); let calls = 0; let signal: AbortSignal | undefined; let address: string | undefined; - app.get('/api/daiso/products', (c) => { calls++; signal=c.req.raw.signal; address=c.req.header('cf-connecting-ip'); return source; }); + app.get('/api/daiso/products', (c) => { + calls++; + signal = c.req.raw.signal; + address = c.req.header('cf-connecting-ip'); + return source; + }); registerBrowseRoutes(app); const get = (path: string, init?: RequestInit) => app.request(path, init, env, context); - return { app, env, context, values, pending, get, resolve, count: () => calls, signal: () => signal, address: () => address }; + return { + app, + env, + context, + values, + pending, + get, + resolve, + count: () => calls, + signal: () => signal, + address: () => address, + }; } afterEach(() => vi.useRealTimers()); @@ -38,7 +70,10 @@ describe('읽기 접수와 실제 결과 전달', () => { vi.useFakeTimers(); const f = fixture(); const controller = new AbortController(); - const request = f.get(`/browse/read/${id}/daiso/products/지갑`, { headers: { 'cf-connecting-ip': '192.0.2.1', authorization: 'do-not-store' }, signal: controller.signal }); + const request = f.get(`/browse/read/${id}/daiso/products/지갑`, { + headers: { 'cf-connecting-ip': '192.0.2.1', authorization: 'do-not-store' }, + signal: controller.signal, + }); await vi.advanceTimersByTimeAsync(1001); const response = await request; const body = await response.text(); @@ -51,7 +86,16 @@ describe('읽기 접수와 실제 결과 전달', () => { const stillPending = await (await f.get(link)).text(); expect(stillPending).toContain('pending'); expect(stillPending.match(/href="([^" ]+)">결과 확인/)![1]).not.toBe(link); - f.resolve(new Response('
원본 요청 거절
', { status: 502, headers: { 'Content-Type': 'text/html', 'Set-Cookie': 'private=do-not-store', 'Retry-After': '12' } })); + f.resolve( + new Response('
원본 요청 거절
', { + status: 502, + headers: { + 'Content-Type': 'text/html', + 'Set-Cookie': 'private=do-not-store', + 'Retry-After': '12', + }, + }), + ); await Promise.all(f.pending); const result = await f.get(link); expect(result.status).toBe(502); @@ -63,7 +107,9 @@ describe('읽기 접수와 실제 결과 전달', () => { }); it('빠른 원본은 기존 응답을 사용하고 작업을 생성하지 않는다', async () => { const f = fixture(); - f.resolve(new Response('
상품 실제 결과
', { headers: { 'Content-Type': 'text/html' } })); + f.resolve( + new Response('
상품 실제 결과
', { headers: { 'Content-Type': 'text/html' } }), + ); const response = await f.get(`/browse/read/${id}/daiso/products/지갑`); expect(await response.text()).toContain('상품 실제 결과'); expect(f.values.size).toBe(0); diff --git a/wrangler.toml b/wrangler.toml index 2f5007f1..bb5fe68f 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -50,3 +50,11 @@ class_name = "GeocodeGateway" [[migrations]] tag = "v3" new_sqlite_classes = ["GeocodeGateway"] + +[[durable_objects.bindings]] +name = "BROWSE_READ_JOBS" +class_name = "BrowseReadJob" + +[[migrations]] +tag = "v4" +new_sqlite_classes = ["BrowseReadJob"] From 9b0687018779131f58ace76ee0c11f9405c96239 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 2 Nov 2026 11:29:45 +0900 Subject: [PATCH 04/10] =?UTF-8?q?test:=20=EC=99=84=EB=A3=8C=20=EC=95=8C?= =?UTF-8?q?=EB=9E=8C=20=EA=B2=BD=ED=95=A9=EA=B3=BC=20=EC=9B=90=EB=B3=B8=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=20=ED=97=A4=EB=8D=94=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B4=20=ED=9A=8C=EA=B7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/pages/browseJobs.test.ts | 13 +++++++++++++ tests/pages/browseJobsFlow.test.ts | 2 ++ 2 files changed, 15 insertions(+) diff --git a/tests/pages/browseJobs.test.ts b/tests/pages/browseJobs.test.ts index 56ab4987..cbca6611 100644 --- a/tests/pages/browseJobs.test.ts +++ b/tests/pages/browseJobs.test.ts @@ -68,6 +68,19 @@ describe('느린 읽기 작업의 저장 경계', () => { vi.useRealTimers(); } }); + it('알람의 오래된 pending 관측으로 정상 결과를 덮어쓰지 않는다', async () => { + vi.useFakeTimers(); + try { + const f = fixture(); + await f.start(); + const old = await (await f.get()).json(); + await f.complete({ status: 200, body: '정상 완료', contentType: 'text/html' }); + await vi.advanceTimersByTimeAsync(25001); + f.storage.get.mockResolvedValueOnce(old); + await f.job.alarm(); + expect(await (await f.get()).json()).toMatchObject({ response: { body: '정상 완료' } }); + } finally { vi.useRealTimers(); } + }); it('잘못된 완료·과대한 본문·미생성 완료·지원하지 않는 요청을 거절한다', async () => { const { job, complete, start } = fixture(); expect((await complete({ status: 200, body: 'ok', contentType: 'text/html' })).status).toBe( diff --git a/tests/pages/browseJobsFlow.test.ts b/tests/pages/browseJobsFlow.test.ts index 2be1054d..20aaf644 100644 --- a/tests/pages/browseJobsFlow.test.ts +++ b/tests/pages/browseJobsFlow.test.ts @@ -80,6 +80,7 @@ describe('읽기 접수와 실제 결과 전달', () => { expect(body).toContain('pending'); expect(body).not.toContain('quantity'); expect(f.address()).toBe('192.0.2.1'); + expect(f.pending.length).toBeGreaterThan(0); controller.abort(); expect(f.signal()?.aborted).toBe(false); const link = body.match(/href="(\/browse\/jobs\/[^" ]+)">결과 확인/)![1]; @@ -101,6 +102,7 @@ describe('읽기 접수와 실제 결과 전달', () => { expect(result.status).toBe(502); expect(await result.text()).toContain('원본 요청 거절'); expect(result.headers.get('set-cookie')).toBeNull(); + expect(result.headers.get('Retry-After')).toBe('12'); expect(result.headers.get('cache-control')).toBe('no-store'); expect(JSON.stringify([...f.values])).not.toContain('do-not-store'); expect(f.count()).toBe(1); From 1d6b2df3395b15e82d6847c7e8dd42df6b98d2a5 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 2 Nov 2026 11:33:12 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20=EC=9D=BD=EA=B8=B0=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EC=88=98=EB=AA=85=EA=B3=BC=20=EC=B7=A8=EC=86=8C=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9D=91=EB=8B=B5=20=EC=A0=80=EC=9E=A5=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/durableObjects/browseReadJob.ts | 56 ++++++--- src/index.ts | 41 ++----- src/pages/browseJobs.ts | 172 +++++++++++++++++---------- src/pages/browseRead.ts | 88 ++++++++------ src/utils/browseReadSignal.ts | 9 ++ src/utils/geocodeProvider.ts | 3 +- src/utils/http.ts | 4 +- tests/pages/browseJobs.test.ts | 4 +- tests/pages/browseJobsFlow.test.ts | 2 +- tests/utils/browseReadSignal.test.ts | 37 ++++++ 10 files changed, 267 insertions(+), 149 deletions(-) create mode 100644 src/utils/browseReadSignal.ts create mode 100644 tests/utils/browseReadSignal.test.ts diff --git a/src/durableObjects/browseReadJob.ts b/src/durableObjects/browseReadJob.ts index a75191b8..d6a10dd5 100644 --- a/src/durableObjects/browseReadJob.ts +++ b/src/durableObjects/browseReadJob.ts @@ -1,7 +1,22 @@ /** 읽기 결과만 잠시 저장합니다. 요청 헤더·인증값·원본 호출 조건은 저장하지 않습니다. */ +export const BROWSE_RESULT_HEADERS = [ + 'retry-after', + 'location', + 'content-security-policy', + 'x-content-type-options', + 'x-daiso-read-id', + 'x-ratelimit-limit', + 'x-ratelimit-remaining', + 'x-ratelimit-reset', +]; export const BROWSE_JOB_TTL = 300000; export const BROWSE_JOB_DEADLINE = 25000; -export type ReadSnapshot = { status: number; body: string; contentType: string }; +export type ReadSnapshot = { + status: number; + body: string; + contentType: string; + headers?: Record; +}; type Job = { status: 'pending' | 'ready'; createdAt: number; @@ -26,7 +41,14 @@ function validSnapshot(input: unknown): input is ReadSnapshot { new TextEncoder().encode(v.body).length <= 262144 && typeof v.contentType === 'string' && v.contentType.length <= 128 && - !/[\r\n]/.test(v.contentType) + !/[\r\n]/.test(v.contentType) && + Object.entries(v.headers ?? {}).every( + ([key, value]) => + BROWSE_RESULT_HEADERS.includes(key) && + typeof value === 'string' && + value.length <= 2048 && + !/[\r\n]/.test(value), + ) ); } @@ -42,10 +64,13 @@ export class BrowseReadJob { return Response.json(current); } if (request.method === 'POST' && path === '/start') { - if (current) return new Response(null, { status: 409 }); - await storage.put('job', { status: 'pending', createdAt: Date.now() }); - await storage.setAlarm(Date.now() + BROWSE_JOB_DEADLINE); - return new Response(null, { status: 201 }); + const accepted = await storage.transaction(async (tx) => { + if (await tx.get('job')) return false; + await tx.put('job', { status: 'pending', createdAt: Date.now() }); + return true; + }); + if (accepted) await storage.setAlarm(Date.now() + BROWSE_JOB_DEADLINE); + return new Response(null, { status: accepted ? 201 : 409 }); } if (request.method !== 'POST' || path !== '/complete') return new Response(null, { status: 405 }); @@ -76,14 +101,17 @@ export class BrowseReadJob { await this.state.storage.deleteAll(); return; } - if (job.status === 'pending' && Date.now() >= job.createdAt + BROWSE_JOB_DEADLINE) { - await this.state.storage.put('job', { - ...job, - status: 'ready', - completedAt: new Date().toISOString(), - response: expiredReadSnapshot(), - }); - } + await this.state.storage.transaction(async (tx) => { + const latest = await tx.get('job'); + if (latest?.status === 'pending' && Date.now() >= latest.createdAt + BROWSE_JOB_DEADLINE) { + await tx.put('job', { + ...latest, + status: 'ready', + completedAt: new Date().toISOString(), + response: expiredReadSnapshot(), + }); + } + }); await this.state.storage.setAlarm( job.createdAt + (Date.now() < job.createdAt + BROWSE_JOB_DEADLINE ? BROWSE_JOB_DEADLINE : BROWSE_JOB_TTL), diff --git a/src/index.ts b/src/index.ts index aaf836ea..8d0948fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,7 +71,6 @@ const mcpSessions = new Map< transport: WebStandardStreamableHTTPServerTransport; } >(); - /** * 요청 컨텍스트 기반 서비스 레지스트리 생성 */ @@ -82,17 +81,12 @@ const createRegistry = (bindings?: AppBindings) => { createDaisoService, () => createDaisoJpService(japanProductTransportFromBindings(bindings)), () => - createGs25Service({ - ...convenienceTransportFromBindings(bindings), + createGs25Service({ ...convenienceTransportFromBindings(bindings), ...geocodeBindings(bindings), zyteApiKey: bindings?.ZYTE_API_KEY, apiKey: bindings?.GS25_API_KEY, }), - () => - createSevenElevenService({ - ...convenienceTransportFromBindings(bindings), - zyteApiKey: bindings?.ZYTE_API_KEY, - }), + () => createSevenElevenService({ ...convenienceTransportFromBindings(bindings), zyteApiKey: bindings?.ZYTE_API_KEY }), () => createCompareService(convenienceTransportFromBindings(bindings)), () => createFeedbackService({ @@ -110,8 +104,7 @@ const createRegistry = (bindings?: AppBindings) => { ...geocodeBindings(bindings), }), () => - createCuService({ - ...convenienceTransportFromBindings(bindings), + createCuService({ ...convenienceTransportFromBindings(bindings), zyteApiKey: bindings?.ZYTE_API_KEY, ...geocodeBindings(bindings), }), @@ -156,14 +149,7 @@ const createMcpServer = (bindings?: AppBindings) => { export function buildRootInfo() { const registry = createRegistry(); - const services = registry - .getServicesInfo() - .map((service) => ({ - ...service, - tools: COMPACT_CATALOG.filter((group) => - group.routes.some((route) => service.tools.includes(route.legacy)), - ).map((group) => group.name), - })); + const services = registry.getServicesInfo().map(service => ({ ...service, tools: COMPACT_CATALOG.filter(group => group.routes.some(route => service.tools.includes(route.legacy))).map(group => group.name) })); const allTools = [...PUBLIC_TOOL_NAMES]; return { @@ -193,9 +179,7 @@ export function buildRootInfo() { } const ROOT_INFO_JSON = JSON.stringify(buildRootInfo()); const ROOT_INFO_BODY = new TextEncoder().encode(ROOT_INFO_JSON); -const ROOT_HTML_BODY = new TextEncoder().encode( - buildDiscoveryAssets(buildRootInfo())['discovery.html'], -); +const ROOT_HTML_BODY = new TextEncoder().encode(buildDiscoveryAssets(buildRootInfo())['discovery.html']); const ROOT_INFO_HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': String(ROOT_INFO_BODY.byteLength), @@ -215,13 +199,9 @@ function createRootInfoResponse(request: Request): Response { const body = json ? ROOT_INFO_BODY : ROOT_HTML_BODY; return new Response(request.method === 'HEAD' ? null : body, { status: 200, - headers: { - ...ROOT_INFO_HEADERS, + headers: { ...ROOT_INFO_HEADERS, 'Content-Type': json ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8', - 'Content-Length': String(body.byteLength), - 'Cache-Control': json ? ROOT_INFO_HEADERS['Cache-Control'] : 'no-store', - Vary: 'Accept', - }, + 'Content-Length': String(body.byteLength), 'Cache-Control': json ? ROOT_INFO_HEADERS['Cache-Control'] : 'no-store', Vary: 'Accept' }, }); } /** @@ -386,9 +366,7 @@ registerServiceRoutes(app); registerBrowseRoutes(app); // 설치 없는 첫 조회 안내 -app.get('/guide/:version', (c) => - createVersionedGuideResponse(new URL(c.req.url).origin, c.req.param('version')), -); +app.get('/guide/:version', (c) => createVersionedGuideResponse(new URL(c.req.url).origin, c.req.param('version'))); app.get('/llms.txt', (c) => createAgentStartResponse(new URL(c.req.url).origin)); // 프롬프트 페이지 (MCP 미지원 에이전트용) @@ -424,8 +402,7 @@ app.get('/privacy', (c) => { // GET API 엔드포인트 (MCP 미지원 에이전트용) app.get('/api/actions/query', async (c) => { - if (['lottemartFindStores', 'lottemartSearchProducts'].includes(c.req.query('action') || '')) - return handleLotteMartFindStores(c); + if (['lottemartFindStores', 'lottemartSearchProducts'].includes(c.req.query('action') || '')) return handleLotteMartFindStores(c); try { const targetUrl = buildActionQueryTargetUrl(c.req.url); const headers = new Headers(c.req.raw.headers); diff --git a/src/pages/browseJobs.ts b/src/pages/browseJobs.ts index 68e60696..43ac865c 100644 --- a/src/pages/browseJobs.ts +++ b/src/pages/browseJobs.ts @@ -3,6 +3,7 @@ import type { Context, Hono } from 'hono'; import type { AppBindings } from '../api/response.js'; import { BROWSE_JOB_DEADLINE, + BROWSE_RESULT_HEADERS, expiredReadSnapshot, type ReadSnapshot, } from '../durableObjects/browseReadJob.js'; @@ -34,7 +35,7 @@ export function registerBrowseJobRoutes(app: Hono): void { const { id, nonce } = c.req.param(); if (!UUID.test(id) || !UUID.test(nonce)) return page('

읽기 작업 주소가 올바르지 않습니다.

', 400); - const namespace = c.env.BROWSE_READ_JOBS; + const namespace = c.env?.BROWSE_READ_JOBS; if (!namespace) return page('

읽기 작업 저장소를 사용할 수 없습니다.

', 503); const response = await namespace .get(namespace.idFromName(id)) @@ -58,84 +59,131 @@ export function registerBrowseJobRoutes(app: Hono): void { 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow', 'X-Daiso-Result-At': job.completedAt!, + ...result.headers, }, }); }); } +/** 과대한 응답을 전부 메모리에 모으기 전에 거절합니다. */ +export async function readBrowseText(response: Response): Promise { + if (!response.body) return ''; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let bytes = 0; + let text = ''; + try { + while (true) { + const next = await reader.read(); + if (next.done) return text + decoder.decode(); + bytes += next.value.byteLength; + if (bytes > 262144) throw new RangeError('BROWSE_RESULT_TOO_LARGE'); + text += decoder.decode(next.value, { stream: true }); + } + } catch (error) { + await reader.cancel().catch(() => {}); + throw error; + } finally { + reader.releaseLock(); + } +} + export async function withBrowseJob( c: Context, - operation: Promise, + operation: () => Promise, cancel: () => void, ): Promise { - const namespace = c.env.BROWSE_READ_JOBS; - if (!namespace) return operation; + const namespace = c.env?.BROWSE_READ_JOBS; + if (!namespace) return operation(); let execution: { waitUntil(promise: Promise): void }; try { execution = c.executionCtx; } catch { - return operation; - } - let timer: ReturnType | undefined; - const first = await Promise.race([ - operation, - new Promise((r) => { - timer = setTimeout(() => r(null), 1000); - }), - ]).finally(() => clearTimeout(timer)); - if (first) return first; - const id = crypto.randomUUID(); - const job = namespace.get(namespace.idFromName(id)); - try { - const started = await job.fetch(new Request('https://job/start', { method: 'POST' })); - if (!started.ok) return operation; - } catch { - return operation; + return operation(); } - const finish = async () => { + const run = async () => { let deadline: ReturnType | undefined; + const source = Promise.race([ + operation(), + new Promise((r) => { + deadline = setTimeout(() => { + cancel(); + const v = expiredReadSnapshot(); + r(new Response(v.body, { status: v.status, headers: { 'Content-Type': v.contentType } })); + }, BROWSE_JOB_DEADLINE); + }), + ]).finally(() => clearTimeout(deadline)); + let timer: ReturnType | undefined; + const first = await Promise.race([ + source, + new Promise((r) => { + timer = setTimeout(() => r(null), 1000); + }), + ]).finally(() => clearTimeout(timer)); + if (first) return first; + const id = crypto.randomUUID(); + let job: ReturnType; try { - const response = await Promise.race([ - operation, - new Promise((r) => { - deadline = setTimeout(() => { - cancel(); - const v = expiredReadSnapshot(); - r( - new Response(v.body, { - status: v.status, - headers: { 'Content-Type': v.contentType }, - }), - ); - }, BROWSE_JOB_DEADLINE - 1000); - }), - ]); - const body = await response.text(); - let snapshot: ReadSnapshot = { - status: response.status, - contentType: response.headers.get('Content-Type') ?? 'text/html; charset=utf-8', - body, - }; - if (new TextEncoder().encode(body).length > 262144) - snapshot = { - status: 502, - contentType: 'text/html; charset=utf-8', - body: '

조회 결과가 저장 크기 제한을 초과했습니다.

수량 미확인. 결과를 잘라 정상으로 표시하지 않습니다.

', - }; - await job.fetch( - new Request('https://job/complete', { method: 'POST', body: JSON.stringify(snapshot) }), - ); + job = namespace.get(namespace.idFromName(id)); + const started = await job.fetch(new Request('https://job/start', { method: 'POST' })); + if (!started.ok) return source; } catch { - const snapshot = expiredReadSnapshot(); - await job - .fetch( - new Request('https://job/complete', { method: 'POST', body: JSON.stringify(snapshot) }), - ) - .catch(() => {}); - } finally { - clearTimeout(deadline); + return source; } + const finish = async () => { + try { + const response = await source; + let body: string; + try { + body = await readBrowseText(response); + } catch (error) { + if (!(error instanceof RangeError)) throw error; + const snapshot: ReadSnapshot = { + status: 502, + contentType: 'text/html; charset=utf-8', + body: '

조회 결과가 저장 크기 제한을 초과했습니다.

수량 미확인. 결과를 잘라 정상으로 표시하지 않습니다.

', + }; + await job.fetch( + new Request('https://job/complete', { method: 'POST', body: JSON.stringify(snapshot) }), + ); + return; + } + const headers = Object.fromEntries( + BROWSE_RESULT_HEADERS.flatMap((key) => { + const value = response.headers.get(key); + return value === null ? [] : [[key, value]]; + }), + ); + const snapshot: ReadSnapshot = { + status: response.status, + contentType: response.headers.get('Content-Type') ?? 'text/html; charset=utf-8', + body, + headers, + }; + await job.fetch( + new Request('https://job/complete', { method: 'POST', body: JSON.stringify(snapshot) }), + ); + } catch { + await job + .fetch( + new Request('https://job/complete', { + method: 'POST', + body: JSON.stringify(expiredReadSnapshot()), + }), + ) + .catch(() => {}); + } + }; + execution.waitUntil(finish()); + return pending(id); }; - execution.waitUntil(finish()); - return pending(id); + const active = run(); + // 처음 지연과 저장소 초기화 중에도 클라이언트 연결 종료로 작업을 잃지 않습니다. + execution.waitUntil( + active.then( + () => {}, + () => {}, + ), + ); + return active; } diff --git a/src/pages/browseRead.ts b/src/pages/browseRead.ts index 5757afff..cae5a5d5 100644 --- a/src/pages/browseRead.ts +++ b/src/pages/browseRead.ts @@ -1,6 +1,7 @@ +import { withBrowseReadSignal } from '../utils/browseReadSignal.js'; /** 웹 도구의 수집 주소를 구분하되 기존 API 캐시와 한도를 유지합니다. */ import type { Context, Hono } from 'hono'; -import { registerBrowseJobRoutes, withBrowseJob } from './browseJobs.js'; +import { registerBrowseJobRoutes, withBrowseJob, readBrowseText } from './browseJobs.js'; import type { AppBindings } from '../api/response.js'; type Bindings = { Bindings: AppBindings }; @@ -22,43 +23,56 @@ export function registerBrowseReadRoutes(app: Hono): void { const requestHeaders = new Headers(c.req.raw.headers); requestHeaders.delete('host'); const controller = new AbortController(); - const operation = Promise.resolve( - app.fetch( - new Request(target, { method: 'GET', headers: requestHeaders, signal: controller.signal }), - c.env, - ), - ).then(async (source) => { - const headers = new Headers(source.headers); - headers.set('Cache-Control', 'no-store'); - headers.set('X-Robots-Tag', 'noindex, nofollow'); - headers.set('X-Daiso-Read-Id', id); - const location = headers.get('Location'); - if (location === '/browse' || location?.startsWith('/browse/')) - headers.set('Location', scoped(location)); - if (!headers.get('Content-Type')?.includes('text/html')) - return new Response(source.body, { status: source.status, headers }); - let body = (await source.text()).replace( - /href="(\/browse(?:\/[^"]*)?)"/g, - (_, path: string) => `href="${scoped(path)}"`, + const operation = () => + withBrowseReadSignal(controller.signal, () => + Promise.resolve( + app.fetch( + new Request(target, { + method: 'GET', + headers: requestHeaders, + signal: controller.signal, + }), + c.env, + ), + ).then(async (source) => { + const headers = new Headers(source.headers); + headers.set('Cache-Control', 'no-store'); + headers.set('X-Robots-Tag', 'noindex, nofollow'); + headers.set('X-Daiso-Read-Id', id); + const location = headers.get('Location'); + if (location === '/browse' || location?.startsWith('/browse/')) + headers.set('Location', scoped(location)); + if (!headers.get('Content-Type')?.includes('text/html')) + return new Response(source.body, { status: source.status, headers }); + let body = ( + await (suffix.startsWith('/daiso/') && c.env?.BROWSE_READ_JOBS + ? readBrowseText(source) + : source.text()) + ).replace( + /href="(\/browse(?:\/[^"]*)?)"/g, + (_, path: string) => `href="${scoped(path)}"`, + ); + const refresh = `/browse/read/${crypto.randomUUID()}${suffix}` + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); + const copyUrl = `https://mcp.aka.page${prefix}${suffix}` + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); + const recovery = `
웹 읽기 접근 거절 시 복사 재전송

아래 문장을 그대로 복사해 이 대화에 보내주세요. 접수나 문장 제시는 재고 조회 완료가 아닙니다.

다음 주소를 웹 URL 열기 도구로 직접 열어 실제 결과를 확인해주세요: ${copyUrl}. 실패하면 오류 원문을 알려주세요.

사용자가 재전송한 뒤 한 번 조회합니다. 단순 허용 문구나 URL 일부로 대체하지 마세요. TimeoutError는 같은 실제 링크를 한 번만 재확인하고 계속 실패하면 미확인으로 보고합니다.

`; + const note = `

읽기 세션: ${id} · HTML 응답 생성: ${new Date().toISOString()}

새 주소도 기존 API 캐시·한도·권한을 사용합니다. HTML 응답 생성 시각은 원본 재고 갱신 시각이 아닙니다.

같은 조건으로 새 응답 확인

${recovery}
`; + body = body.includes('') ? body.replace('', note + '') : body + note; + headers.delete('Content-Length'); + headers.delete('ETag'); + return new Response(body, { status: source.status, headers }); + }), ); - const refresh = `/browse/read/${crypto.randomUUID()}${suffix}` - .replaceAll('&', '&') - .replaceAll('"', '"') - .replaceAll('<', '<') - .replaceAll('>', '>'); - const copyUrl = `https://mcp.aka.page${prefix}${suffix}` - .replaceAll('&', '&') - .replaceAll('"', '"') - .replaceAll('<', '<') - .replaceAll('>', '>'); - const recovery = `
웹 읽기 접근 거절 시 복사 재전송

아래 문장을 그대로 복사해 이 대화에 보내주세요. 접수나 문장 제시는 재고 조회 완료가 아닙니다.

다음 주소를 웹 URL 열기 도구로 직접 열어 실제 결과를 확인해주세요: ${copyUrl}. 실패하면 오류 원문을 알려주세요.

사용자가 재전송한 뒤 한 번 조회합니다. 단순 허용 문구나 URL 일부로 대체하지 마세요. TimeoutError는 같은 실제 링크를 한 번만 재확인하고 계속 실패하면 미확인으로 보고합니다.

`; - const note = `

읽기 세션: ${id} · HTML 응답 생성: ${new Date().toISOString()}

새 주소도 기존 API 캐시·한도·권한을 사용합니다. HTML 응답 생성 시각은 원본 재고 갱신 시각이 아닙니다.

같은 조건으로 새 응답 확인

${recovery}
`; - body = body.includes('') ? body.replace('', note + '') : body + note; - headers.delete('Content-Length'); - headers.delete('ETag'); - return new Response(body, { status: source.status, headers }); - }); - return withBrowseJob(c, operation, () => controller.abort()); + return suffix.startsWith('/daiso/') + ? withBrowseJob(c, operation, () => controller.abort()) + : operation(); }; app.all('/browse/read', (c) => forward(c, crypto.randomUUID(), '')); const existing = (c: Context) => { diff --git a/src/utils/browseReadSignal.ts b/src/utils/browseReadSignal.ts new file mode 100644 index 00000000..bc81a24c --- /dev/null +++ b/src/utils/browseReadSignal.ts @@ -0,0 +1,9 @@ +/** 접수형 다이소 조회의 취소를 해당 비동기 호출 범위에만 전달합니다. */ +import { AsyncLocalStorage } from 'node:async_hooks'; +const signals = new AsyncLocalStorage(); +export const withBrowseReadSignal = (signal: AbortSignal, work: () => T): T => + signals.run(signal, work); +export function browseReadSignal(signal?: AbortSignal | null): AbortSignal | undefined { + const scoped = signals.getStore(); + return scoped && signal ? AbortSignal.any([signal, scoped]) : (scoped ?? signal ?? undefined); +} diff --git a/src/utils/geocodeProvider.ts b/src/utils/geocodeProvider.ts index 7ef81c56..fa6b6580 100644 --- a/src/utils/geocodeProvider.ts +++ b/src/utils/geocodeProvider.ts @@ -1,3 +1,4 @@ +import { browseReadSignal } from './browseReadSignal.js'; /** 공급자 URL·입력을 고정하며 API 키와 조회 응답은 저장하지 않습니다. */ import type { AppBindings } from '../api/response.js'; import type { GeocodeProvider, GeocodeReply } from './geocodeGate.js'; @@ -156,7 +157,7 @@ export async function requestGeocodeProvider( method: 'GET', headers: geocodeHeaders(row.provider, credentials), redirect: 'manual', - signal: controller.signal, + signal: browseReadSignal(controller.signal), }); if (controller.signal.aborted) { await response.body?.cancel(); diff --git a/src/utils/http.ts b/src/utils/http.ts index 3237cec0..bb8c4ab0 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -1,3 +1,4 @@ +import { browseReadSignal } from './browseReadSignal.js'; import { parseRelayQuota, type RelayQuota } from './relayQuota.js'; import { diagnosticEvent } from './diagnostics.js'; /** @@ -134,7 +135,7 @@ async function requestWithTimeout( const { timeout = 10000, totalTimeout, - signal: callerSignal, + signal: optionSignal, fetchImpl = fetch, retries = 0, retryDelayMs = 250, @@ -145,6 +146,7 @@ async function requestWithTimeout( expectedStatus: _expectedStatus, ...restOptions } = options; + const callerSignal = browseReadSignal(optionSignal); const maxAttempts = Math.max(1, Math.trunc(retries) + 1); const method = normalizeMethod(restOptions.method); const retryAllowed = canRetryMethod(method, retryMethods, retryUnsafeMethods); diff --git a/tests/pages/browseJobs.test.ts b/tests/pages/browseJobs.test.ts index cbca6611..caa376d7 100644 --- a/tests/pages/browseJobs.test.ts +++ b/tests/pages/browseJobs.test.ts @@ -79,7 +79,9 @@ describe('느린 읽기 작업의 저장 경계', () => { f.storage.get.mockResolvedValueOnce(old); await f.job.alarm(); expect(await (await f.get()).json()).toMatchObject({ response: { body: '정상 완료' } }); - } finally { vi.useRealTimers(); } + } finally { + vi.useRealTimers(); + } }); it('잘못된 완료·과대한 본문·미생성 완료·지원하지 않는 요청을 거절한다', async () => { const { job, complete, start } = fixture(); diff --git a/tests/pages/browseJobsFlow.test.ts b/tests/pages/browseJobsFlow.test.ts index 20aaf644..9c07d154 100644 --- a/tests/pages/browseJobsFlow.test.ts +++ b/tests/pages/browseJobsFlow.test.ts @@ -115,7 +115,7 @@ describe('읽기 접수와 실제 결과 전달', () => { const response = await f.get(`/browse/read/${id}/daiso/products/지갑`); expect(await response.text()).toContain('상품 실제 결과'); expect(f.values.size).toBe(0); - expect(f.pending).toHaveLength(0); + expect(f.pending).toHaveLength(1); }); it('작업 조회의 잘못된 ID·메서드·만료를 구별하고 원본을 재호출하지 않는다', async () => { const f = fixture(); diff --git a/tests/utils/browseReadSignal.test.ts b/tests/utils/browseReadSignal.test.ts new file mode 100644 index 00000000..e2c2de2d --- /dev/null +++ b/tests/utils/browseReadSignal.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { browseReadSignal, withBrowseReadSignal } from '../../src/utils/browseReadSignal.js'; +import { fetchJson } from '../../src/utils/http.js'; + +describe('접수 작업 취소 범위', () => { + it('범위 밖 요청은 바꾸지 않고 범위 안의 호출자 신호와 함께 취소한다', async () => { + const caller = new AbortController(); + expect(browseReadSignal()).toBeUndefined(); + expect(browseReadSignal(caller.signal)).toBe(caller.signal); + const scoped = new AbortController(); + await withBrowseReadSignal(scoped.signal, async () => { + expect(browseReadSignal()).toBe(scoped.signal); + const both = browseReadSignal(caller.signal)!; + caller.abort(); + expect(both.aborted).toBe(true); + }); + expect(browseReadSignal()).toBeUndefined(); + }); + it('실제 공용 HTTP 요청의 fetch 신호까지 작업 취소를 전달한다', async () => { + const scoped = new AbortController(); + let signal: AbortSignal | undefined; + const work = withBrowseReadSignal(scoped.signal, () => + fetchJson('https://example.test', { + fetchImpl: async (_url, init) => { + signal = init?.signal as AbortSignal; + return new Promise((_resolve, reject) => + signal!.addEventListener('abort', () => reject(signal!.reason), { once: true }), + ); + }, + }), + ); + await Promise.resolve(); + scoped.abort(new Error('read job deadline')); + await expect(work).rejects.toThrow('read job deadline'); + expect(signal?.aborted).toBe(true); + }); +}); From 177f9e022f1fcbdd85c31e0d70204ac33000a58c Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 2 Nov 2026 11:35:36 +0900 Subject: [PATCH 06/10] =?UTF-8?q?test:=20=EC=A0=91=EC=88=98=20=EC=A0=84=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EC=B7=A8=EC=86=8C=EC=99=80=20=EA=B3=BC?= =?UTF-8?q?=EB=8C=80=20HTML=20=EC=A7=84=EB=8B=A8=20=ED=9A=8C=EA=B7=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/pages/browseJobsFlow.test.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/pages/browseJobsFlow.test.ts b/tests/pages/browseJobsFlow.test.ts index 9c07d154..4cd7ce82 100644 --- a/tests/pages/browseJobsFlow.test.ts +++ b/tests/pages/browseJobsFlow.test.ts @@ -74,7 +74,11 @@ describe('읽기 접수와 실제 결과 전달', () => { headers: { 'cf-connecting-ip': '192.0.2.1', authorization: 'do-not-store' }, signal: controller.signal, }); - await vi.advanceTimersByTimeAsync(1001); + await vi.advanceTimersByTimeAsync(1); + expect(f.pending.length).toBeGreaterThan(0); + controller.abort(); + expect(f.signal()?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1000); const response = await request; const body = await response.text(); expect(body).toContain('pending'); @@ -117,6 +121,24 @@ describe('읽기 접수와 실제 결과 전달', () => { expect(f.values.size).toBe(0); expect(f.pending).toHaveLength(1); }); + it.each(['fast', 'slow'])('%s 과대 HTML을 시간 초과가 아닌 명시적 크기 오류로 반환한다', async (mode) => { + vi.useFakeTimers(); + const f = fixture(); + const large = new Response('가'.repeat(90000), { headers: { 'Content-Type': 'text/html' } }); + if (mode === 'fast') f.resolve(large); + const request = f.get(`/browse/read/${id}/daiso/products/지갑`); + if (mode === 'slow') await vi.advanceTimersByTimeAsync(1001); + const first = await request; + let result = first; + if (mode === 'slow') { + const link = (await first.text()).match(/href="(\/browse\/jobs\/[^" ]+)">결과 확인/)![1]; + f.resolve(large); + await Promise.all(f.pending); + result = await f.get(link); + } + expect(result.status).toBe(502); + expect(await result.text()).toContain('크기 제한'); + }); it('작업 조회의 잘못된 ID·메서드·만료를 구별하고 원본을 재호출하지 않는다', async () => { const f = fixture(); expect((await f.get('/browse/jobs/bad/bad')).status).toBe(400); From bba5cc058e3d1a24967dc6d3d21876c075d6c95c Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 2 Nov 2026 11:37:35 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20=EA=B3=BC=EB=8C=80=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=A7=84=EB=8B=A8=EA=B3=BC=20=EC=A0=91=EC=88=98=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=ED=9A=8C=EA=B7=80=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/browseRead.ts | 22 +++- tests/pages/browseJobsFailures.test.ts | 159 +++++++++++++++++++++++++ tests/pages/browseJobsFlow.test.ts | 39 +++--- 3 files changed, 198 insertions(+), 22 deletions(-) create mode 100644 tests/pages/browseJobsFailures.test.ts diff --git a/src/pages/browseRead.ts b/src/pages/browseRead.ts index cae5a5d5..088b43d6 100644 --- a/src/pages/browseRead.ts +++ b/src/pages/browseRead.ts @@ -44,11 +44,25 @@ export function registerBrowseReadRoutes(app: Hono): void { headers.set('Location', scoped(location)); if (!headers.get('Content-Type')?.includes('text/html')) return new Response(source.body, { status: source.status, headers }); - let body = ( - await (suffix.startsWith('/daiso/') && c.env?.BROWSE_READ_JOBS + let raw: string; + try { + raw = await (suffix.startsWith('/daiso/') && c.env?.BROWSE_READ_JOBS ? readBrowseText(source) - : source.text()) - ).replace( + : source.text()); + } catch (error) { + if (!(error instanceof RangeError)) throw error; + return new Response( + '

조회 결과가 웹 읽기 크기 제한을 초과했습니다.

수량 미확인. 결과를 잘라 정상으로 표시하지 않습니다.

', + { + status: 502, + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + }, + }, + ); + } + let body = raw.replace( /href="(\/browse(?:\/[^"]*)?)"/g, (_, path: string) => `href="${scoped(path)}"`, ); diff --git a/tests/pages/browseJobsFailures.test.ts b/tests/pages/browseJobsFailures.test.ts new file mode 100644 index 00000000..91fc1e97 --- /dev/null +++ b/tests/pages/browseJobsFailures.test.ts @@ -0,0 +1,159 @@ +import { Hono } from 'hono'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { AppBindings } from '../../src/api/response.js'; +import { + readBrowseText, + withBrowseJob, + registerBrowseJobRoutes, +} from '../../src/pages/browseJobs.js'; + +function fixture(mode = 'normal') { + let resolve!: (r: Response) => void; + let reject!: (e: Error) => void; + const source = new Promise((a, b) => { + resolve = a; + reject = b; + }); + const writes: unknown[] = []; + const pending: Promise[] = []; + const context = { + waitUntil: (p: Promise) => { + pending.push(p); + }, + } as ExecutionContext; + let failures = 0; + const env = { + BROWSE_READ_JOBS: { + idFromName: () => 'id', + get: () => { + if (mode === 'lookupfail') throw new Error('storage unavailable'); + return { + fetch: async (r: Request) => { + if (new URL(r.url).pathname === '/start') + return new Response(null, { status: mode === 'startfail' ? 503 : 201 }); + if (mode === 'writefail' && failures++ === 0) throw new Error('write failed'); + if (mode === 'allwritefail') throw new Error('write failed'); + writes.push(await r.json()); + return new Response(null, { status: 200 }); + }, + }; + }, + }, + } as unknown as AppBindings; + const cancel = vi.fn(); + const app = new Hono<{ Bindings: AppBindings }>(); + app.onError((_e, c) => c.text('original failure', 500)); + app.get('/', (c) => withBrowseJob(c, () => source, cancel)); + return { + app, + env, + context, + pending, + writes, + resolve, + reject, + cancel, + get: () => app.request('/', {}, env, context), + }; +} +afterEach(() => vi.useRealTimers()); + +describe('접수형 조회 실패 경계', () => { + it.each(['lookupfail', 'startfail'])('%s이면 진행 중인 원본 응답으로 돌아간다', async (mode) => { + vi.useFakeTimers(); + const f = fixture(mode); + const work = f.get(); + await vi.advanceTimersByTimeAsync(1001); + f.resolve(new Response('original response')); + expect(await (await work).text()).toBe('original response'); + await Promise.all(f.pending); + expect(f.writes).toHaveLength(0); + }); + it('실행 컨텍스트가 없는 로컬 클라이언트는 기존 동기 응답을 사용한다', async () => { + const f = fixture(); + f.resolve(new Response('sync')); + expect(await (await f.app.request('/', {}, f.env)).text()).toBe('sync'); + }); + it('시간 제한은 취소를 호출하고 저장 상태를 미확인504로 남긴다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const request = f.get(); + await vi.advanceTimersByTimeAsync(1001); + expect(await (await request).text()).toContain('pending'); + await vi.advanceTimersByTimeAsync(24000); + await Promise.all(f.pending); + expect(f.cancel).toHaveBeenCalledOnce(); + expect(f.writes).toEqual([expect.objectContaining({ status: 504 })]); + }); + it.each(['normal', 'writefail', 'allwritefail'])( + '%s 작업 오류를 미확인 상태로 처리한다', + async (mode) => { + vi.useFakeTimers(); + const f = fixture(mode); + const request = f.get(); + await vi.advanceTimersByTimeAsync(1001); + await request; + if (mode === 'normal') f.reject(new Error('source unavailable')); + else f.resolve(new Response('ready')); + await Promise.all(f.pending); + if (mode !== 'allwritefail') + expect(f.writes).toEqual([expect.objectContaining({ status: 504 })]); + else expect(f.writes).toHaveLength(0); + }, + ); + it('빠른 원본 거절을 작업 성공으로 바꾸지 않는다', async () => { + const f = fixture(); + const work = f.get(); + f.reject(new Error('early error')); + expect((await work).status).toBe(500); + await Promise.all(f.pending); + }); + it('마지막 본문의 크기·스트림 오류도 정상 결과로 저장하지 않는다', async () => { + vi.useFakeTimers(); + for (const error of [false, true]) { + const f = fixture(); + const work = f.get(); + await vi.advanceTimersByTimeAsync(1001); + await work; + f.resolve( + error + ? new Response( + new ReadableStream({ + start(c) { + c.error(new Error('read failed')); + }, + }), + ) + : new Response('가'.repeat(90000)), + ); + await Promise.all(f.pending); + expect(f.writes).toEqual([expect.objectContaining({ status: error ? 504 : 502 })]); + } + }); + it('빈 응답·다중 UTF8 청크를 읽고 저장소 없는 주소는503으로 표시한다', async () => { + expect(await readBrowseText(new Response(null))).toBe(''); + const bytes = new TextEncoder().encode('지갑'); + expect( + await readBrowseText( + new Response( + new ReadableStream({ + start(c) { + c.enqueue(bytes.slice(0, 1)); + c.enqueue(bytes.slice(1)); + c.close(); + }, + }), + ), + ), + ).toBe('지갑'); + const app = new Hono<{ Bindings: AppBindings }>(); + registerBrowseJobRoutes(app); + expect( + ( + await app.request( + '/browse/jobs/12345678-1234-4123-8123-123456789abc/12345678-1234-4123-8123-123456789abc', + ) + ).status, + ).toBe(503); + }); +}); diff --git a/tests/pages/browseJobsFlow.test.ts b/tests/pages/browseJobsFlow.test.ts index 4cd7ce82..e0ad99ca 100644 --- a/tests/pages/browseJobsFlow.test.ts +++ b/tests/pages/browseJobsFlow.test.ts @@ -121,24 +121,27 @@ describe('읽기 접수와 실제 결과 전달', () => { expect(f.values.size).toBe(0); expect(f.pending).toHaveLength(1); }); - it.each(['fast', 'slow'])('%s 과대 HTML을 시간 초과가 아닌 명시적 크기 오류로 반환한다', async (mode) => { - vi.useFakeTimers(); - const f = fixture(); - const large = new Response('가'.repeat(90000), { headers: { 'Content-Type': 'text/html' } }); - if (mode === 'fast') f.resolve(large); - const request = f.get(`/browse/read/${id}/daiso/products/지갑`); - if (mode === 'slow') await vi.advanceTimersByTimeAsync(1001); - const first = await request; - let result = first; - if (mode === 'slow') { - const link = (await first.text()).match(/href="(\/browse\/jobs\/[^" ]+)">결과 확인/)![1]; - f.resolve(large); - await Promise.all(f.pending); - result = await f.get(link); - } - expect(result.status).toBe(502); - expect(await result.text()).toContain('크기 제한'); - }); + it.each(['fast', 'slow'])( + '%s 과대 HTML을 시간 초과가 아닌 명시적 크기 오류로 반환한다', + async (mode) => { + vi.useFakeTimers(); + const f = fixture(); + const large = new Response('가'.repeat(90000), { headers: { 'Content-Type': 'text/html' } }); + if (mode === 'fast') f.resolve(large); + const request = f.get(`/browse/read/${id}/daiso/products/지갑`); + if (mode === 'slow') await vi.advanceTimersByTimeAsync(1001); + const first = await request; + let result = first; + if (mode === 'slow') { + const link = (await first.text()).match(/href="(\/browse\/jobs\/[^" ]+)">결과 확인/)![1]; + f.resolve(large); + await Promise.all(f.pending); + result = await f.get(link); + } + expect(result.status).toBe(502); + expect(await result.text()).toContain('크기 제한'); + }, + ); it('작업 조회의 잘못된 ID·메서드·만료를 구별하고 원본을 재호출하지 않는다', async () => { const f = fixture(); expect((await f.get('/browse/jobs/bad/bad')).status).toBe(400); From b89aad040793a74e09372e43093097d3d17b8233 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 2 Nov 2026 11:38:43 +0900 Subject: [PATCH 08/10] =?UTF-8?q?test:=20=EB=B9=88=20=EC=99=84=EB=A3=8C?= =?UTF-8?q?=EC=99=80=20=EC=9B=90=EB=B3=B8=20=EC=8A=A4=ED=8A=B8=EB=A6=BC=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EA=B5=AC=EB=B6=84=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/pages/browseJobsFailures.test.ts | 12 ++++++++++++ tests/pages/browseJobsFlow.test.ts | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/tests/pages/browseJobsFailures.test.ts b/tests/pages/browseJobsFailures.test.ts index 91fc1e97..10727a21 100644 --- a/tests/pages/browseJobsFailures.test.ts +++ b/tests/pages/browseJobsFailures.test.ts @@ -130,6 +130,18 @@ describe('접수형 조회 실패 경계', () => { expect(f.writes).toEqual([expect.objectContaining({ status: error ? 504 : 502 })]); } }); + it('본문이 없는 완료는 기본 HTML 타입으로 저장한다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const work = f.get(); + await vi.advanceTimersByTimeAsync(1001); + await work; + f.resolve(new Response(null)); + await Promise.all(f.pending); + expect(f.writes).toEqual([ + expect.objectContaining({ status: 200, body: '', contentType: 'text/html; charset=utf-8' }), + ]); + }); it('빈 응답·다중 UTF8 청크를 읽고 저장소 없는 주소는503으로 표시한다', async () => { expect(await readBrowseText(new Response(null))).toBe(''); const bytes = new TextEncoder().encode('지갑'); diff --git a/tests/pages/browseJobsFlow.test.ts b/tests/pages/browseJobsFlow.test.ts index e0ad99ca..170e1fa0 100644 --- a/tests/pages/browseJobsFlow.test.ts +++ b/tests/pages/browseJobsFlow.test.ts @@ -142,6 +142,24 @@ describe('읽기 접수와 실제 결과 전달', () => { expect(await result.text()).toContain('크기 제한'); }, ); + it('원본 HTML 스트림 오류는 크기 초과로 오진하지 않는다', async () => { + const f = fixture(); + f.app.onError((_e, c) => c.text('원본 본문 실패', 500)); + f.resolve( + new Response( + new ReadableStream({ + start(c) { + c.error(new Error('body failed')); + }, + }), + { headers: { 'Content-Type': 'text/html' } }, + ), + ); + const result = await f.get(`/browse/read/${id}/daiso/products/지갑`); + expect(result.status).toBe(500); + expect(await result.text()).toBe('원본 본문 실패'); + await Promise.all(f.pending); + }); it('작업 조회의 잘못된 ID·메서드·만료를 구별하고 원본을 재호출하지 않는다', async () => { const f = fixture(); expect((await f.get('/browse/jobs/bad/bad')).status).toBe(400); From 75da0561f6b343df2f0d03b2ce72908eabafbec0 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 2 Nov 2026 11:41:07 +0900 Subject: [PATCH 09/10] =?UTF-8?q?docs:=20=EC=A0=91=EC=88=98=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EC=9D=98=20=EC=83=88=20=EA=B2=B0=EA=B3=BC=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=EC=99=80=20=EC=99=84=EB=A3=8C=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 ++++++---- docs/ai-instruction.md | 5 ++++- docs/client-guide.md | 3 +++ docs/service-reference.md | 3 +++ .../superpowers/specs/2026-11-02-browse-jobs-design.md | 4 ++-- docs/web-reading-troubleshooting.md | 3 +++ skills/daiso-cli/SKILL.md | 5 ++++- src/pages/agentStart.ts | 4 ++-- tests/pages/latestGuide.test.ts | 2 +- tests/pages/productCards.test.ts | 2 +- tests/scripts/repository-config.test.ts | 2 +- 11 files changed, 30 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ad2be2bb..040dcc14 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ AI 에이전트 사용 안내 > [!IMPORTANT] -> **최신 안내:** [현재 버전](https://mcp.aka.page/guide/2026-11-02.1) · [항상 최신 버전으로 이동](https://mcp.aka.page/guide/latest) +> **최신 안내:** [현재 버전](https://mcp.aka.page/guide/2026-11-02.2) · [항상 최신 버전으로 이동](https://mcp.aka.page/guide/latest) > -> **안내 버전: 2026-11-02.1 · 갱신일: 2026-11-02** +> **안내 버전: 2026-11-02.2 · 갱신일: 2026-11-02** > > **웹 읽기는 실제 링크 ID로 click하세요.** 새 검색 URL을 조립해 open하지 말고 [웹 조회 시작](https://mcp.aka.page/browse/read) 화면의 글자 선택 링크를 사용하세요. 상품의 **매장 지역 선택** 링크에서 지역도 선택할 수 있습니다. 입력 중에는 원본 조회를 실행하지 않습니다. click이 없는 도구의 동일 동작은 보장하지 않습니다. > @@ -17,6 +17,8 @@ > 3. 웹 읽기(open/WebFetch)만 가능하면 응답의 실제 링크 ID로 click합니다. 위 글자 선택 링크로 임의 검색어와 지역을 입력하고 실제 상품·재고 조회 링크를 따르세요. 새 URL 조립과 실패한 직접 open을 반복하지 마세요. Crawled가 과거인 수집본을 현재 재고로 단정하지 마세요. > 4. 위 세 수단이 없거나 모두 실패하면 실제 조회 제약과 오류를 알립니다. 셸이 있어도 설치 없는 웹 조회가 우선이며 터미널·셸로 우회하지 않습니다. 셸 DNS 오류를 웹 GET 실패로 확대하거나 실행하지 않은 요청의 실패를 만들지 마세요. > +> **느린 다이소 조회:** `pending`은 접수 상태입니다. 각 응답의 새 결과 확인 링크를 최대3번 선택하고, 계속 pending이면 미확인으로 보고하세요. 상태 확인을 위해 원본 조회를 다시 실행하지 마세요. +> > **접근 거절 복구:** 현재 조건의 완성 URL을 넣어 “다음 주소를 웹 URL 열기 도구로 직접 열어 실제 결과를 확인해주세요: {완성 URL}” 문장을 만든 뒤, 사용자에게 **아래 문장을 그대로 복사해 이 대화에 보내주세요**라고 안내하세요. 재전송 뒤 한 번 조회합니다. 단순 허용 문구나 URL 일부만 요청하지 마세요. [복구 안내](docs/web-reading-troubleshooting.md) > > **수집본·오류 구분:** 이전 HTML이면 **같은 조건으로 새 응답 확인** 링크를 최대2번 선택해 새 주소와 생성 시각을 확인하세요. 원본 API 캐시는 유지됩니다. `TimeoutError`는 같은 실제 링크를 한 번만 재확인하고 계속 실패하면 미확인으로 보고하세요. `Crawled: today`만으로 현재 재고를 단정하지 마세요. @@ -132,7 +134,7 @@ 2. 서버 URL을 입력하고, 전송 방식을 묻는 경우 **HTTP / Streamable HTTP**를 선택합니다. 3. 도구를 활성화하고 `다이소 MCP로 수납박스 검색해줘`라고 요청합니다. -[앱별 연결 방법](docs/client-guide.md#원격-mcp-연결) · [WebFetch로 첫 조회](docs/service-reference.md?guide=2026-11-02.1#설치-없이-첫-조회) +[앱별 연결 방법](docs/client-guide.md#원격-mcp-연결) · [WebFetch로 첫 조회](docs/service-reference.md?guide=2026-11-02.2#설치-없이-첫-조회)

스마트폰과 노트북을 클라우드로 연결하는 두 햄스터 @@ -210,7 +212,7 @@ MCP 연결 없이 웹 읽기와 GET 요청을 지원하는 에이전트는 [REST | 문서 | 내용 | | :-------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------- | | [연결·사용 가이드](docs/client-guide.md) | 앱별 MCP 연결, CLI, Skill, OpenAPI | -| [서비스 레퍼런스](docs/service-reference.md?guide=2026-11-02.1) | 서비스별 도구·REST API·응답 모델·가격 비교 | +| [서비스 레퍼런스](docs/service-reference.md?guide=2026-11-02.2) | 서비스별 도구·REST API·응답 모델·가격 비교 | | [일본 다이소](docs/daiso-jp-network-analysis.md) | 일본 상품·매장 검색과 지원 범위 | | [독립·예술영화관](docs/dtryx.md) | 디트릭스 지원 극장과 조회 방법 | | [AI 조회 가이드](docs/ai-instruction.md) | 도구 선택과 상품·매장·재고 조회 순서 | diff --git a/docs/ai-instruction.md b/docs/ai-instruction.md index d068ae80..bceecbd4 100644 --- a/docs/ai-instruction.md +++ b/docs/ai-instruction.md @@ -1,11 +1,14 @@ # DAISO MCP 에이전트 지침 +다이소의 읽기 세션 조회가 오래 걸리면 `pending` 접수와 실제 **결과 확인** 링크를 반환합니다. 접수는 상품·재고 확인 성공이 아닙니다. 각 응답의 새로운 결과 링크를 최대3번 선택하고, 계속 pending이면 미확인으로 보고하세요. 이전 상태 주소를 반복하거나 원본 조회를 다시 실행하지 마세요. 상태 확인은 저장 결과만 읽으며, 완료 결과는 원본의 성공·오류 상태를 유지합니다. HTML·작업 완료 시각과 원본 재고 갱신 시각은 다릅니다. 작업이 만료되거나 시간 초과된 경우에도 수량을0으로 바꾸지 않습니다. + + 웹 읽기 접근 거절이 계속되면 **현재 조건의 완성 URL을 넣은 복사용 문장**을 제시하고 “아래 문장을 그대로 복사해 이 대화에 보내주세요”라고 안내하세요. 사용자 재전송 뒤 한 번 조회합니다. 단순 허용 문구·URL 일부 제공·실행하지 않은 실패 보고로 대체하지 마세요. [복사 재전송 및 오류 구분](https://github.com/hmmhmmhm/daiso-mcp/blob/main/docs/web-reading-troubleshooting.md)을 따릅니다. ## 설치 없이 첫 조회 -최신 서버 안내는 [현재 버전](https://mcp.aka.page/guide/2026-11-02.1)과 [최신 연결](https://mcp.aka.page/guide/latest)에서 제공합니다. 웹 도구는 과거 리디렉션·수집본을 반환할 수 있으므로 표시된 버전과 HTML 생성 시각을 확인하세요. +최신 서버 안내는 [현재 버전](https://mcp.aka.page/guide/2026-11-02.2)과 [최신 연결](https://mcp.aka.page/guide/latest)에서 제공합니다. 웹 도구는 과거 리디렉션·수집본을 반환할 수 있으므로 표시된 버전과 HTML 생성 시각을 확인하세요. 1. 연결된 MCP 도구가 있으면 `search_products`에 `{"service":"daiso","query":"수납박스","pageSize":3}`를 전달합니다. 2. 실제 브라우저 도구가 있으면 /browse/ HTML GET 주소를 열고 실제 상품·매장·재고를 읽습니다. Work의 내장 브라우저도 사용 가능합니다. diff --git a/docs/client-guide.md b/docs/client-guide.md index ce44bdc5..07e5328a 100644 --- a/docs/client-guide.md +++ b/docs/client-guide.md @@ -1,5 +1,8 @@ # 앱 연결·CLI·Skill 가이드 +다이소의 읽기 세션 조회가 오래 걸리면 `pending` 접수와 실제 **결과 확인** 링크를 반환합니다. 접수는 상품·재고 확인 성공이 아닙니다. 각 응답의 새로운 결과 링크를 최대3번 선택하고, 계속 pending이면 미확인으로 보고하세요. 이전 상태 주소를 반복하거나 원본 조회를 다시 실행하지 마세요. 상태 확인은 저장 결과만 읽으며, 완료 결과는 원본의 성공·오류 상태를 유지합니다. HTML·작업 완료 시각과 원본 재고 갱신 시각은 다릅니다. 작업이 만료되거나 시간 초과된 경우에도 수량을0으로 바꾸지 않습니다. + + 웹 읽기 접근 거절이 계속되면 **현재 조건의 완성 URL을 넣은 복사용 문장**을 제시하고 “아래 문장을 그대로 복사해 이 대화에 보내주세요”라고 안내하세요. 사용자 재전송 뒤 한 번 조회합니다. 단순 허용 문구·URL 일부 제공·실행하지 않은 실패 보고로 대체하지 마세요. [복사 재전송 및 오류 구분](https://github.com/hmmhmmhm/daiso-mcp/blob/main/docs/web-reading-troubleshooting.md)을 따릅니다. diff --git a/docs/service-reference.md b/docs/service-reference.md index 926f3ee2..1f102c4c 100644 --- a/docs/service-reference.md +++ b/docs/service-reference.md @@ -1,5 +1,8 @@ # DAISO MCP 서비스 레퍼런스 +다이소의 읽기 세션 조회가 오래 걸리면 `pending` 접수와 실제 **결과 확인** 링크를 반환합니다. 접수는 상품·재고 확인 성공이 아닙니다. 각 응답의 새로운 결과 링크를 최대3번 선택하고, 계속 pending이면 미확인으로 보고하세요. 이전 상태 주소를 반복하거나 원본 조회를 다시 실행하지 마세요. 상태 확인은 저장 결과만 읽으며, 완료 결과는 원본의 성공·오류 상태를 유지합니다. HTML·작업 완료 시각과 원본 재고 갱신 시각은 다릅니다. 작업이 만료되거나 시간 초과된 경우에도 수량을0으로 바꾸지 않습니다. + + 웹 읽기 접근 거절이 계속되면 **현재 조건의 완성 URL을 넣은 복사용 문장**을 제시하고 “아래 문장을 그대로 복사해 이 대화에 보내주세요”라고 안내하세요. 사용자 재전송 뒤 한 번 조회합니다. 단순 허용 문구·URL 일부 제공·실행하지 않은 실패 보고로 대체하지 마세요. [복사 재전송 및 오류 구분](https://github.com/hmmhmmhm/daiso-mcp/blob/main/docs/web-reading-troubleshooting.md)을 따릅니다. diff --git a/docs/superpowers/specs/2026-11-02-browse-jobs-design.md b/docs/superpowers/specs/2026-11-02-browse-jobs-design.md index 8c740f0a..f382a656 100644 --- a/docs/superpowers/specs/2026-11-02-browse-jobs-design.md +++ b/docs/superpowers/specs/2026-11-02-browse-jobs-design.md @@ -2,9 +2,9 @@ 2026-11-02 사용자 승인과 별도 Worker 실험을 따른다. 실제 ChatGPT 새 임시 채팅2건에서5초 가상 작업의 접수→서로 다른 실제 결과 링크→ready 및 서버 requestId 일치를 확인했다. 같은 주소 재시도는 실패/과거 pending 재사용이 관측됐다. -- 기존 /browse/read의 실제 API 호출을 한 번 실행한다.1초 안에 끝나면 기존 응답을 반환한다. 느린 경우에만 별도 읽기 작업을 만들고 pending HTML 및 새 실제 결과 확인 링크를 반환한다. +- 우선 다이소 /browse/read 읽기 세션에만 적용하고 다른 서비스의 기존 경로는 유지한다. 실제 API 호출을 한 번 실행한다.1초 안에 끝나면 기존 응답을 반환한다. 느린 경우에만 별도 읽기 작업을 만들고 pending HTML 및 새 실제 결과 확인 링크를 반환한다. - 원본 요청의 권한·IP별 한도·캐시를 기존 API에서 적용한다. 후속 상태 확인은 저장 결과만 읽고 원본을 다시 호출하지 않는다. 공개 MCP10도구/JSON/CLI와 일반 /browse 경로를 유지한다. - 원본 호출과 HTML 장식을 waitUntil로 유지하고 사용자 연결 취소와 분리한다. 작업의25초 제한·5분 TTL·256KiB 저장 상한을 둔다. 제한·외부 오류·미확인 수량을 정상 재고0으로 바꾸지 않는다. -- 요청 헤더·인증·쿠키·원본 조회 조건은 작업 저장소에 보관하지 않는다. 안전한 공개 응답 본문·상태·Content-Type만 저장한다. 작업 주소는 무작위 UUID이며 상태 응답마다 다른 실제 후속 링크를 제공한다. HTML 생성·작업 완료·원본 재고 갱신 시각은 구별한다. +- 요청 헤더·인증·쿠키·원본 조회 조건은 작업 저장소에 보관하지 않는다. 안전한 공개 응답 본문·상태·Content-Type과 재시도·리디렉션·보안 헤더 허용 목록만 저장한다. 작업 주소는 무작위 UUID이며 상태 응답마다 다른 실제 후속 링크를 제공한다. HTML 생성·작업 완료·원본 재고 갱신 시각은 구별한다. - 저장 실패 시 기존 동기 응답으로 복귀한다. 늦은 완료/알람의 경합으로 정상 결과를 덮어쓰지 않는다. 대기·만료는 HTML로 명확히 보고한다. - 저장 및 순차 실제 API 경계를 가상시계로 검증하고, 필수 CI/Coverage100%/리뷰/배포 이후 실제 공개 조회와 ChatGPT 웹에서 상태 링크를 확인한다. 검증용 브라우저는 소유 창1개, 검사와 겹치지 않는다. diff --git a/docs/web-reading-troubleshooting.md b/docs/web-reading-troubleshooting.md index 8b9c1230..6bf48b55 100644 --- a/docs/web-reading-troubleshooting.md +++ b/docs/web-reading-troubleshooting.md @@ -1,5 +1,8 @@ # 웹 읽기 접근 오류 진단 +다이소의 읽기 세션 조회가 오래 걸리면 `pending` 접수와 실제 **결과 확인** 링크를 반환합니다. 접수는 상품·재고 확인 성공이 아닙니다. 각 응답의 새로운 결과 링크를 최대3번 선택하고, 계속 pending이면 미확인으로 보고하세요. 이전 상태 주소를 반복하거나 원본 조회를 다시 실행하지 마세요. 상태 확인은 저장 결과만 읽으며, 완료 결과는 원본의 성공·오류 상태를 유지합니다. HTML·작업 완료 시각과 원본 재고 갱신 시각은 다릅니다. 작업이 만료되거나 시간 초과된 경우에도 수량을0으로 바꾸지 않습니다. + + 2026-10-26 실험에서는 같은 재고 주소가 일반 HTTPS, MCP SDK, Work 내장 브라우저에서는 정상 동작했지만 웹 읽기 도구에서는 접근 오류가 발생했습니다. 일부 기존 링크는 웹 읽기로 열렸으나 새 주소나 동일 주소의 직접 열기는 실패했습니다. 과거 수집 시점의 결과가 반환되는 경우도 확인했습니다. Cloudflare 관측 구간에는 정상 페이지의 OpenAI 요청에서 200과 클라이언트 연결 종료인 499가 관측됐습니다. 403 차단은 관측되지 않았습니다. 로그의 표본 추출과 수집 지연 때문에 요청이 보이지 않는다는 이유만으로 서버에 도달하지 않았다고 확정할 수는 없습니다. 웹 읽기 내부의 실패 원인이나 시간 제한은 확인되지 않았으며 서버 변경으로 해결됐다고 주장하지 않습니다. diff --git a/skills/daiso-cli/SKILL.md b/skills/daiso-cli/SKILL.md index 6bf054cb..09996b7d 100644 --- a/skills/daiso-cli/SKILL.md +++ b/skills/daiso-cli/SKILL.md @@ -15,6 +15,9 @@ metadata: - daiso homepage: https://github.com/hmmhmmhm/daiso-mcp --- + +다이소의 읽기 세션 조회가 오래 걸리면 `pending` 접수와 실제 **결과 확인** 링크를 반환합니다. 접수는 상품·재고 확인 성공이 아닙니다. 각 응답의 새로운 결과 링크를 최대3번 선택하고, 계속 pending이면 미확인으로 보고하세요. 이전 상태 주소를 반복하거나 원본 조회를 다시 실행하지 마세요. 상태 확인은 저장 결과만 읽으며, 완료 결과는 원본의 성공·오류 상태를 유지합니다. HTML·작업 완료 시각과 원본 재고 갱신 시각은 다릅니다. 작업이 만료되거나 시간 초과된 경우에도 수량을0으로 바꾸지 않습니다. + 웹 읽기 접근 거절이 계속되면 **현재 조건의 완성 URL을 넣은 복사용 문장**을 제시하고 “아래 문장을 그대로 복사해 이 대화에 보내주세요”라고 안내하세요. 사용자 재전송 뒤 한 번 조회합니다. 단순 허용 문구·URL 일부 제공·실행하지 않은 실패 보고로 대체하지 마세요. [복사 재전송 및 오류 구분](https://github.com/hmmhmmhm/daiso-mcp/blob/main/docs/web-reading-troubleshooting.md)을 따릅니다. @@ -24,7 +27,7 @@ Use this skill to operate the Daiso MCP project through `npx daiso` and the publ ## 설치 없이 첫 조회 -최신 서버 안내는 [현재 버전](https://mcp.aka.page/guide/2026-11-02.1)과 [최신 연결](https://mcp.aka.page/guide/latest)에서 제공합니다. 웹 도구는 과거 리디렉션·수집본을 반환할 수 있으므로 표시된 버전과 HTML 생성 시각을 확인하세요. +최신 서버 안내는 [현재 버전](https://mcp.aka.page/guide/2026-11-02.2)과 [최신 연결](https://mcp.aka.page/guide/latest)에서 제공합니다. 웹 도구는 과거 리디렉션·수집본을 반환할 수 있으므로 표시된 버전과 HTML 생성 시각을 확인하세요. 1. 연결된 MCP 도구가 있으면 `search_products`에 `{"service":"daiso","query":"수납박스","pageSize":3}`를 전달합니다. 2. 실제 브라우저 도구가 있으면 /browse/ HTML GET 주소를 열고 실제 상품·매장·재고를 읽습니다. Work의 내장 브라우저도 사용 가능합니다. diff --git a/src/pages/agentStart.ts b/src/pages/agentStart.ts index 16038702..0201390e 100644 --- a/src/pages/agentStart.ts +++ b/src/pages/agentStart.ts @@ -1,5 +1,5 @@ /** 검색·읽기 사본의 안내 세대를 구분하는 고정 버전 */ -export const AGENT_GUIDE_VERSION = '2026-11-02.1'; +export const AGENT_GUIDE_VERSION = '2026-11-02.2'; export const AGENT_GUIDE_UPDATED = '2026-11-02'; export const AGENT_GUIDE_STAMP = `안내 버전: ${AGENT_GUIDE_VERSION} · 갱신일: ${AGENT_GUIDE_UPDATED}`; export const AGENT_GUIDE_PATH = `/guide/${AGENT_GUIDE_VERSION}`; @@ -8,7 +8,7 @@ export const WEB_SEARCH_DESCRIPTION = /** 완성 URL을 사용자 메시지로 제공하는 복구 경로이며 자동 허용을 보장하지 않습니다. */ export const WEB_READ_RECOVERY_RULE = - '접근 거절(not accessible/Invalid URL)이 계속되거나 click이 없으면 현재 조건의 완성 공개 URL을 포함해 “다음 주소를 웹 URL 열기 도구로 직접 열어 실제 결과를 확인해주세요: {완성 URL}. 실패하면 오류 원문을 알려주세요.” 문장을 만드세요. 사용자에게 “아래 문장을 그대로 복사해 이 대화에 보내주세요”라고 안내하고 실제 URL로 채운 문장을 별도 코드 블록에 제공합니다. 실제 링크가 있으면 그대로 사용하고 없을 때만 사용자 검색 조건으로 복구용 주소를 구성하세요. 상품 ID가 없으면 상품 검색부터 요청하며 재고 ID를 추측하지 마세요. 사용자가 재전송한 뒤 한 번 조회합니다. 단순 허용 문구나 주소 일부만 요청하지 마세요. 문장을 제시한 것은 실제 조회가 아닙니다. 토큰·쿠키를 포함하지 마세요. TimeoutError의 같은 링크 재확인은 한 번만 하며 실패 결과가 캐시되어 다음에 성공한다고 가정하지 마세요.'; + '다이소 읽기 조회에서 pending 접수가 반환되면 완료 결과가 아닙니다. 각 응답의 새로운 실제 결과 확인 링크를 최대3번 선택하고, 계속 pending이면 미확인으로 보고하세요. 상태 확인을 위해 원본 상품·재고 조회를 새로 실행하지 마세요. 결과의 HTML 생성·작업 완료 시각도 원본 재고 갱신 시각과 다릅니다. 접근 거절(not accessible/Invalid URL)이 계속되거나 click이 없으면 현재 조건의 완성 공개 URL을 포함해 “다음 주소를 웹 URL 열기 도구로 직접 열어 실제 결과를 확인해주세요: {완성 URL}. 실패하면 오류 원문을 알려주세요.” 문장을 만드세요. 사용자에게 “아래 문장을 그대로 복사해 이 대화에 보내주세요”라고 안내하고 실제 URL로 채운 문장을 별도 코드 블록에 제공합니다. 실제 링크가 있으면 그대로 사용하고 없을 때만 사용자 검색 조건으로 복구용 주소를 구성하세요. 상품 ID가 없으면 상품 검색부터 요청하며 재고 ID를 추측하지 마세요. 사용자가 재전송한 뒤 한 번 조회합니다. 단순 허용 문구나 주소 일부만 요청하지 마세요. 문장을 제시한 것은 실제 조회가 아닙니다. 토큰·쿠키를 포함하지 마세요. TimeoutError의 같은 링크 재확인은 한 번만 하며 실패 결과가 캐시되어 다음에 성공한다고 가정하지 마세요.'; /** 웹 읽기 환경에서 쿼리 없는 HTML 경로를 우선합니다. */ export const WEB_READ_RULE = diff --git a/tests/pages/latestGuide.test.ts b/tests/pages/latestGuide.test.ts index d4479d78..3b21443a 100644 --- a/tests/pages/latestGuide.test.ts +++ b/tests/pages/latestGuide.test.ts @@ -4,7 +4,7 @@ import app from '../../src/index.js'; import { buildDiscoveryAssets } from '../../src/pages/discovery.js'; import { apiResultHtml } from '../../src/pages/apiResult.js'; -const version = '2026-11-02.1'; +const version = '2026-11-02.2'; describe('최신 안내 경로와 원본 이미지', () => { it.each(['/guide/latest', '/guide/2026-10-21.2', '/guide/unknown'])( '%s는 캐시 없이 현재 버전으로 연결한다', diff --git a/tests/pages/productCards.test.ts b/tests/pages/productCards.test.ts index bde5f8ed..28d02c5d 100644 --- a/tests/pages/productCards.test.ts +++ b/tests/pages/productCards.test.ts @@ -99,7 +99,7 @@ describe('실제 상품 이미지 카드', () => { const assets = buildDiscoveryAssets({ services: [], tools: [] }); expect(assets['discovery.html']).toMatch(/name="description" content="ChatGPT.*browse\//); for (const text of [assets['discovery.html'], buildAgentStartText('https://example.com')]) { - expect(text).toContain('안내 버전: 2026-11-02.1'); + expect(text).toContain('안내 버전: 2026-11-02.2'); expect(text).toContain('갱신일: 2026-11-02'); } }); diff --git a/tests/scripts/repository-config.test.ts b/tests/scripts/repository-config.test.ts index 6e5cad28..06b23dd5 100644 --- a/tests/scripts/repository-config.test.ts +++ b/tests/scripts/repository-config.test.ts @@ -315,7 +315,7 @@ describe('repository maintenance configuration', () => { expect(fastStart).toContain('설치·API 키 없이'); expect(fastStart).toMatch(/\| 인증\s*\| 없음\s*\|/); expect(readme).toContain('로컬 설치·빌드·API 키 준비 없이'); - expect(fastStart).toContain('docs/service-reference.md?guide=2026-11-02.1#설치-없이-첫-조회'); + expect(fastStart).toContain('docs/service-reference.md?guide=2026-11-02.2#설치-없이-첫-조회'); const directCall = readText('docs/service-reference.md').split('### MCP 표준 응답 모델')[0]; expect(directCall).toContain('WebFetch'); expect(directCall).toContain('/browse/daiso/products/'); From 75700e956f3558a1ef89f9a1cde6eabc68d151f0 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 2 Nov 2026 11:41:40 +0900 Subject: [PATCH 10/10] =?UTF-8?q?test:=20=EC=8B=A4=EC=A0=9C=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EC=8B=9C=EA=B0=84=20=EC=A0=9C=ED=95=9C=EA=B3=BC=20?= =?UTF-8?q?=EB=8A=A6=EC=9D=80=20=EC=99=84=EB=A3=8C=20=EA=B2=A9=EB=A6=AC=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/pages/browseJobsFlow.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/pages/browseJobsFlow.test.ts b/tests/pages/browseJobsFlow.test.ts index 170e1fa0..1bfbf612 100644 --- a/tests/pages/browseJobsFlow.test.ts +++ b/tests/pages/browseJobsFlow.test.ts @@ -160,6 +160,21 @@ describe('읽기 접수와 실제 결과 전달', () => { expect(await result.text()).toBe('원본 본문 실패'); await Promise.all(f.pending); }); + it('실제 읽기 작업의 시간 제한은 자식 요청을 취소하고 늦은 완료를 덮어쓰지 않는다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const work = f.get(`/browse/read/${id}/daiso/products/지갑`); + await vi.advanceTimersByTimeAsync(1001); + const link = (await (await work).text()).match(/href="(\/browse\/jobs\/[^" ]+)">결과 확인/)![1]; + await vi.advanceTimersByTimeAsync(24000); + await Promise.all(f.pending); + expect(f.signal()?.aborted).toBe(true); + expect((await f.get(link)).status).toBe(504); + f.resolve(new Response('

늦은 결과
', { headers: { 'Content-Type': 'text/html' } })); + await vi.advanceTimersByTimeAsync(1); + expect((await f.get(link)).status).toBe(504); + expect(f.count()).toBe(1); + }); it('작업 조회의 잘못된 ID·메서드·만료를 구별하고 원본을 재호출하지 않는다', async () => { const f = fixture(); expect((await f.get('/browse/jobs/bad/bad')).status).toBe(400);