이 가이드는 처음부터 최소한의 샌드박스 플러그인을 구축하는 방법을 안내합니다. 이 플러그인은 모든 콘텐츠 저장을 로깅하고 단일 API 경로를 노출합니다. 완료되면 구성된 샌드박스 러너를 통해 격리된 런타임에서 실행되는 플러그인을 갖게 됩니다. 사이트 운영자가 sandboxed: []에서 plugins: []로 이동하기로 선택하면 동일한 플러그인 코드를 인프로세스로 실행할 수도 있습니다. 예를 들어 샌드박스 러너를 사용할 수 없는 플랫폼에서 말이죠.
샌드박스 플러그인과 네이티브 플러그인 중 어느 것을 원하는지 아직 결정하지 못했다면 먼저 플러그인 형식 선택을 읽어보세요.
두 개의 파일
모든 샌드박스 플러그인은 두 부분으로 구성됩니다:
- 디스크립터 — 플러그인을 설명하는 작은 객체(id, 버전, 기능, 스토리지, 런타임 진입점 위치). 빌드 시
astro.config.mjs에 의해 가져옵니다. - 샌드박스 진입점 — 런타임 코드: 후크, 경로, 스토리지 액세스. 요청 시 샌드박스 런타임에 로드됩니다.
두 파일은 동일한 패키지에 있지만 완전히 다른 환경에서 실행됩니다. 디스크립터는 런타임 컨텍스트를 볼 수 없습니다. 진입점은 astro.config.mjs를 볼 수 없습니다.
my-plugin/
├── src/
│ ├── index.ts # 디스크립터 — 빌드 시 Vite에서 실행
│ └── sandbox-entry.ts # 후크, 경로, 스토리지 — 샌드박스 런타임에서 실행
├── package.json
└── tsconfig.json
패키지 설정
-
새 디렉토리를 만들고 TypeScript ES 모듈 패키지로 초기화합니다.
{ "name": "@my-org/plugin-hello", "version": "0.1.0", "type": "module", "main": "dist/index.mjs", "exports": { ".": { "import": "./dist/index.mjs", "types": "./dist/index.d.mts" }, "./sandbox": "./dist/sandbox-entry.mjs" }, "files": ["dist"], "scripts": { "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean" }, "peerDependencies": { "emdash": "*" }, "devDependencies": { "emdash": "*", "tsdown": "^0.6.0", "typescript": "^5.5.0" } }"./sandbox"내보내기는 디스크립터의entrypoint가 가리킬 대상입니다. 번들러는 두 파일을 모두dist/에 빌드합니다. -
tsconfig.json을 추가합니다:{ "compilerOptions": { "target": "ES2022", "module": "preserve", "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "declaration": true, "outDir": "./dist", "rootDir": "./src" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] }
디스크립터 작성
디스크립터는 PluginDescriptor를 반환하는 팩토리 함수입니다. 빌드 시 Vite에서 실행되므로 부작용이 없어야 하며 런타임 API(fetch, 데이터베이스, 환경 변수 등 아직 존재하지 않는 것들)를 사용할 수 없습니다.
import type { PluginDescriptor } from "emdash";
export function helloPlugin(): PluginDescriptor {
return {
id: "plugin-hello",
version: "0.1.0",
format: "standard",
entrypoint: "@my-org/plugin-hello/sandbox",
capabilities: [],
storage: {
events: { indexes: ["timestamp"] },
},
};
}
중요한 세부 사항:
format: "standard"는 필수입니다. 이것이 없으면 EmDash는 패키지를 네이티브 플러그인으로 처리하고 다른 구조를 찾습니다.format필드의 기본값은"native"입니다.entrypoint는 모듈 지정자입니다, 파일 경로가 아닙니다.import에 전달하는 것과 동일한 문자열을 사용합니다 — 일반적으로"<package-name>/sandbox"입니다. 패키지 이름은 스코프가 있을 수 있지만(@my-org/plugin-hello) 플러그인id는 없습니다.id는 URL 안전 슬러그입니다, npm 패키지 이름이 아닙니다./^[a-z][a-z0-9_-]*$/와 일치해야 합니다 — 소문자로 시작하고 그 다음에 문자, 숫자, 하이픈 또는 밑줄이 옵니다. id는 플러그인 경로 URL의 단일 경로 세그먼트(/_emdash/api/plugins/<id>/...)로 사용되며 플러그인 스토리지 인덱스에 대해 생성된 SQL 식별자의 일부로도 사용되므로@,/, 선행 숫자 및 대문자는 모두 런타임에 실패합니다.plugin-hello와 같은 스코프가 없는id를entrypoint의 스코프가 있는 npm 패키지 이름과 쌍을 이룹니다.- 기능, allowedHosts 및 스토리지는 디스크립터에 있습니다. 샌드박스 진입점은 이를 선언하지 않습니다 — 디스크립터가 허용하는 것만 사용할 수 있습니다.
- 여기에 런타임 로직을 넣지 마세요. 최상위
await없음, 모듈 수준fetch없음, 파일 읽기 없음. 디스크립터는 메타데이터입니다.
샌드박스 진입점 작성
런타임 측. 이 파일은 요청 시 샌드박스 런타임에 로드되며 ctx가 제공하는 것 외에는 아무것도 액세스할 수 없습니다.
import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";
interface ContentSaveEvent {
collection: string;
content: { id: string };
isNew: boolean;
}
export default definePlugin({
hooks: {
"content:afterSave": {
handler: async (event: ContentSaveEvent, ctx: PluginContext) => {
ctx.log.info("Content saved", {
collection: event.collection,
id: event.content.id,
});
await ctx.storage.events.put(`save-${Date.now()}`, {
timestamp: new Date().toISOString(),
collection: event.collection,
contentId: event.content.id,
});
},
},
},
routes: {
recent: {
handler: async (_routeCtx, ctx: PluginContext) => {
const result = await ctx.storage.events.query({ limit: 10 });
return { events: result.items };
},
},
},
});
알아야 할 사항:
- 샌드박스 진입점의
definePlugin()은{ hooks, routes }만 받습니다.id,version,capabilities는 없습니다 — 이들은 디스크립터에서 나옵니다. 여기서 전달하려고 하면 EmDash는 빌드 시 throw합니다. - 후크 핸들러는
(event, ctx)를 받습니다. 이벤트 형태는 후크 이름에 따라 다릅니다. 후크 참조를 참조하세요. - 경로 핸들러는
(routeCtx, ctx)를 받습니다 — 두 개의 인수.routeCtx에는{ input, request, requestMeta }가 있습니다.ctx는 후크에서 얻는 것과 동일한PluginContext입니다. 경로는/_emdash/api/plugins/<plugin-id>/<route-name>에서 도달할 수 있습니다. ctx.storage.events는 디스크립터에events가 선언되었기 때문에 작동합니다. 선언되지 않은 컬렉션에 액세스하면 throw됩니다.ctx.kv는 항상 사용 가능합니다 —get,set,delete및list(prefix)가 있는 플러그인별 키-값 저장소입니다.
플러그인 등록
사이트의 astro.config.mjs에서 디스크립터 팩토리를 가져와 EmDash 통합에 전달합니다. 샌드박스 플러그인은 sandboxed: []에 들어갑니다. 인프로세스 플러그인은 plugins: []에 들어갑니다. 표준 형식 플러그인은 둘 다에서 작동합니다 — sandboxed로 시작합니다.
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { sandbox } from "@emdash-cms/cloudflare";
import { helloPlugin } from "@my-org/plugin-hello";
export default defineConfig({
integrations: [
emdash({
sandboxed: [helloPlugin()],
sandboxRunner: sandbox(),
}),
],
});
sandboxRunner는 플러그 가능한 부분입니다. 위의 예는 @emdash-cms/cloudflare의 sandbox()를 사용하며, 오늘날 대부분의 사이트에서 사용하는 샌드박스 러너입니다. 다른 플랫폼용 러너가 개발 중입니다. 러너가 구성되지 않았거나 구성된 러너가 현재 플랫폼에서 사용할 수 없다고 보고하는 경우 sandboxed: [] 플러그인은 시작 시 건너뜁니다 — 동일한 플러그인을 인프로세스로 실행하려면 sandboxed: []에서 plugins: []로 이동합니다.
빌드 및 실행
플러그인 디렉토리에서:
pnpm build
사이트 디렉토리에서 플러그인을 링크하거나 설치하고(pnpm add @my-org/plugin-hello 또는 작업 공간 링크) 개발 서버를 시작합니다. 다음에 관리자에서 콘텐츠를 저장할 때 로그에 [hello] Content saved …가 표시되어야 하며 GET /_emdash/api/plugins/plugin-hello/recent는 마지막 10개의 저장 이벤트를 반환해야 합니다.