앱 코드
defineApp, 실행 문맥(ctx), 이벤트 함수, 공개 HTTP, 예약 작업, 저장소, 외부 호출과 실행 한도, 로그
앱 코드는 src/server.ts 하나에서 시작하는 TypeScript 모듈입니다. 웹 표준 API(fetch·Request·Response·crypto 등)로 실행되며
Node.js 전용 모듈(fs·net·child_process)은 쓸 수 없습니다. 패키지는 package.json에 적고 npm install하면 deploy가 번들에 넣습니다.
import { defineApp } from "@sayren/app";
export default defineApp({
events: {
"ORDER.PAID": async (event, ctx) => {
/* … */
},
},
});events는 매니페스트 events에 적은 이벤트마다 함수 하나입니다. 함수가 없는 이벤트가 있으면 빌드가 HANDLER_MISSING으로 실패합니다.
http(앱 화면·외부가 부르는 핸들러)는 HTTP 핸들러와 공개 HTTP에, schedules(주기 실행)는
예약 작업에 있습니다.
실행 문맥 ctx
| 속성 | 뜻 |
|---|---|
ctx.installation | installationId · appId · versionNumber · storeId · storeName |
ctx.api | 설치된 상점의 Store SDK 클라이언트. 동의한 권한으로 관리 API를 부릅니다. 토큰은 sayren이 호출마다 넣습니다 |
ctx.settings | 셀러가 채운 설정값(Record<string, string | number | boolean | null>). 비밀 필드도 들어 있습니다 |
ctx.secrets | 개발자 비밀 값(Record<string, string>) |
ctx.user | 앱 화면에서 온 HTTP 호출에서만. 화면을 연 사람의 userId와 역할(OWNER · ADMIN). 공개 HTTP·이벤트·예약 작업에는 없습니다 |
ctx.storage | 설치별 저장소. 아래 저장소 |
ctx.publicUrl(path) | 이 설치의 공개 HTTP 주소(https://{handle}.sayrenapps.com/hooks/{설치 id}{path}). 메일·외부 서비스에 넣을 링크를 만듭니다 |
ctx.log | info·warn·error. console.*와 같이 로그에 남습니다 |
ctx.invocationId | 이 호출의 id. 재시도는 같은 값으로 옵니다 |
ctx.token | 설치 토큰 문자열. ctx.api 대신 직접 fetch로 관리 API를 부를 때 Authorization: Bearer에 싣습니다. 설치 해제·데이터 삭제 호출에서는 null입니다 |
ctx.api는 상점이 고정된 클라이언트라 X-Store-Id를 넣을 필요가 없습니다. 권한 밖의 호출은 403 INSUFFICIENT_SCOPE입니다. 구성원·결제 설정처럼
상점 플랫폼에서만 하는 작업은 권한과 무관하게 403입니다.
이벤트 함수
"ORDER.PAID": async (event, ctx) => {
// event: { id, type: "ORDER.PAID", createdAt, storeId, sequence, data: { orderId, orderNo, … } }
}event는 웹훅과 같은 모양입니다. data의 필드는 이벤트에 있습니다.
TypeScript에서는 함수 키가 이벤트 이름이면 event.data가 그 이벤트의 모양으로 좁혀집니다("ORDER.PAID"의 event.data.orderId는 string).
매니페스트에 쓸 수 없는 이름은 타입 오류입니다. 여러 이벤트를 한 함수에서 받으면 AppEvent 유니온을 event.type으로 좁힙니다.
import { type AppEvent, defineApp } from "@sayren/app";
export default defineApp({
events: {
"ORDER.PAID": async (event, ctx) => {
const order = await ctx.api.orders.get(event.data.orderId); // 캐스팅 없이
},
"APP.SETTINGS_UPDATED": async (event) => {
console.log("바뀐 설정", event.data.changedKeys);
},
},
});- 최소 한 번 전달됩니다. 함수가 예외를 던지거나 한도를 넘으면 뒤에 다시 실행됩니다(간격을 늘리며 최대 24시간). 같은 호출은 같은
ctx.invocationId로 오므로, 외부에 두 번 보내면 안 되는 작업은 이 값으로 중복을 막으십시오. - 순서는 보장하지 않습니다. 같은 주문의 이벤트도 먼저 생긴 것이 늦게 올 수 있습니다.
event.sequence(리소스별 번호)로 순서를 판단합니다. - 함수의 반환값은 쓰지 않습니다. 정상으로 끝나면 성공으로 기록됩니다.
- 필수 설정이 비어 있는 상점에는 이벤트가 오지 않습니다(실행 기록에 「설정 필요」로 남습니다).
HTTP 핸들러
"POST /notes": async (request, ctx) => {
const body = await request.json();
if (ctx.user?.role !== "OWNER") return new Response("소유자만", { status: 403 });
return Response.json({ saved: true });
}앱 화면의 코드가 @sayren/app/ui로 부릅니다(상점 플랫폼 앱 화면). 요청 본문은 1MB까지입니다. 응답은 그대로
앱 화면에 전달됩니다. 핸들러는 설치 토큰으로 동작하므로 사람의 역할로 권한이 좁혀지지 않습니다. 역할에 따라 막을 작업은 ctx.user.role로
직접 판단하십시오. 지금은 소유자·관리자만 앱 화면을 엽니다.
공개 HTTP
매니페스트 http.public에 적은 핸들러는 sayren 밖에서도 부를 수 있습니다. 구매자에게 보내는 메일 속 링크, 외부 서비스의 콜백(결제·택배·메일
발송 결과)을 받을 때 씁니다. 주소는 설치마다 다릅니다.
https://{handle}.sayrenapps.com/hooks/{설치 id}/track/ord_01J… → "GET /track/:orderId"export default defineApp({
events: {
"ORDER.PAID": async (event, ctx) => {
const link = ctx.publicUrl(`/track/${event.data.orderId}`);
// link를 구매자 메일에 넣는다
},
},
http: {
"GET /track/:orderId": async (request, ctx) => {
const orderId = new URL(request.url).pathname.split("/").pop() ?? "";
const order = await ctx.api.orders.get(orderId);
return new Response(`<h1>${order.orderNo} 제작 중</h1>`, {
headers: { "content-type": "text/html; charset=utf-8" },
});
},
},
});- 누구나 부를 수 있습니다. 로그인·세션이 없고
ctx.user도 없습니다. 주소를 아는 사람에게 보여도 되는 것만 돌려주고, 링크마다 추측하기 어려운 값(주문 id 등)을 넣으십시오. 외부 서비스 콜백은 그 서비스의 서명 헤더를 검증하십시오(요청 헤더는Cookie를 빼고 전달됩니다). - 핸들러는 설치 토큰으로 실행되므로
ctx.api·ctx.settings·ctx.storage를 그대로 씁니다. - 메서드는
GET·POST·PUT·PATCH·DELETE, 본문은 1MB까지입니다. 응답의Set-Cookie는 버립니다. - 설치가 제거되거나 앱이 정지되면
404입니다.http.public에 없는 경로도404입니다. - 한 번만 실행됩니다. 실패해도 sayren이 다시 부르지 않습니다(외부 서비스의 재시도에 맡깁니다).
예약 작업
export default defineApp({
schedules: {
dailyReport: async (ctx) => {
const page = await ctx.api.orders.list({ orderedFrom: yesterday(), size: 100 });
await ctx.storage.set("lastReport", { count: page.totalElements });
},
},
});매니페스트 schedules의 이름마다 함수 하나입니다. 설치된 상점마다 every 간격으로 실행됩니다.
- 첫 실행은 설치(또는 재동의) 뒤 다음 주기 판정 때이고, 그 뒤
every마다 돕니다. 정확한 시각은 보장하지 않습니다(몇 분 늦을 수 있습니다). - 실패해도 다시 실행하지 않습니다. 다음 주기에 다시 돕니다.
- 필수 설정이 비어 있는 상점에서는 돌지 않습니다(실행 기록에 「설정 필요」).
- 함수의 인자는
ctx하나입니다. 이번 실행의 예약 작업 이름은ctx.schedule입니다.
저장소
ctx.storage는 설치(상점)마다 따로인 키-값 저장소입니다. 값은 JSON으로 저장되고 호출 사이에 남습니다.
await ctx.storage.set("lastSyncedAt", new Date().toISOString());
const last = await ctx.storage.get<string>("lastSyncedAt"); // 없으면 null
await ctx.storage.delete("lastSyncedAt");
const keys = await ctx.storage.list({ prefix: "order:" }); // 키 목록(이름순)| 항목 | 한도 |
|---|---|
| 키 | 영문·숫자·- _ . : 200자 |
| 키 개수 | 설치당 1,000개 |
| 전체 크기 | 설치당 1MB(키 + JSON 값) |
- 한도를 넘는
set은409 APP_STORAGE_LIMIT로 실패합니다. - 같은 키에 동시에 쓰면 나중 쓰기가 남습니다.
- 저장소 호출은 관리 API 호출로 셉니다(상점당 분당 120).
- 설치를 제거하면 48시간 뒤 설정과 함께 지워집니다. 제거·데이터 삭제 호출(
ctx.token이null)에서는 쓸 수 없습니다.
외부 호출
fetch는 매니페스트 network.hosts와 sayren API에만 나갑니다. 나머지는 403이고 로그에 OUTBOUND_BLOCKED로 남습니다.
https://·443 포트만 되고 리다이렉트를 따라가지 않습니다. WebSocket·TCP 연결은 쓸 수 없습니다.
실행 한도
| 항목 | 한도 |
|---|---|
| 호출당 CPU 시간 | 50ms. 외부 호출을 기다리는 시간은 세지 않습니다 |
| 호출당 외부·API 요청 수 | 50 |
| 호출당 실행 시간 | 30초 |
| 메모리 | 128MB |
| 앱 전체 동시 실행 | 20 |
| 상점(설치)당 이벤트 | 분당 300. 넘으면 뒤로 미룹니다 |
| 상점당 관리 API 호출 | 분당 120. 넘으면 429이고 Retry-After를 보고 다시 부릅니다 |
한도를 넘은 호출은 LIMIT_EXCEEDED로 기록되고 이벤트는 다시 실행됩니다. 앱이 부른 관리 API는 설치된 상점의 API 사용량으로 셉니다.
전역 상태
같은 앱 코드가 여러 상점의 호출을 처리합니다. 모듈 전역 변수는 호출 사이, 상점 사이에 공유될 수 있고 언제든 사라질 수 있습니다.
상점별 데이터는 ctx에서 읽고 전역에 두지 마십시오. 호출 사이에 남길 값은 저장소에 둡니다.
로그
console.log·ctx.log.*와 잡히지 않은 예외가 호출 단위로 기록됩니다. npx sayren app logs --tail 또는 developer.sayren.io › {앱} › 로그에서
7일 동안 봅니다. 설치 토큰과 비밀 설정·비밀 값이 로그에 섞이면 가려서 저장합니다. 그래도 비밀 값을 로그에 찍지 마십시오.
셀러에게는 로그 본문이 보이지 않고 호출 결과만 보입니다.
패키지
@sayren/app—defineApp과ctx타입.@sayren/app/ui는 앱 화면용,@sayren/app/testing은 로컬 테스트용입니다.@sayren/store-sdk—ctx.api의 타입. 직접 설치하지 않아도 됩니다.- 그 밖의 패키지는
package.json에 적습니다. 알려진 보안 취약점이 있는 패키지, 레지스트리 밖(git·파일) 의존성은 올리기가 거절합니다(pnpm-lock.yaml이 있으면 함께 검사합니다). 서버 번들은 3MB(gzip)까지입니다.