첫 번째 샌드박스 플러그인

이 페이지

이 가이드는 처음부터 최소한의 샌드박스 플러그인을 구축하는 방법을 안내합니다. 이 플러그인은 모든 콘텐츠 저장을 로깅하고 단일 API 경로를 노출합니다. 완료되면 구성된 샌드박스 러너를 통해 격리된 런타임에서 실행되는 플러그인을 갖게 됩니다. 사이트 운영자가 sandboxed: []에서 plugins: []로 이동하기로 선택하면 동일한 플러그인 코드를 인프로세스로 실행할 수도 있습니다. 예를 들어 샌드박스 러너를 사용할 수 없는 플랫폼에서 말이죠.

샌드박스 플러그인과 네이티브 플러그인 중 어느 것을 원하는지 아직 결정하지 못했다면 먼저 플러그인 형식 선택을 읽어보세요.

두 개의 파일

모든 샌드박스 플러그인은 두 부분으로 구성됩니다:

  1. 디스크립터 — 플러그인을 설명하는 작은 객체(id, 버전, 기능, 스토리지, 런타임 진입점 위치). 빌드 시 astro.config.mjs에 의해 가져옵니다.
  2. 샌드박스 진입점 — 런타임 코드: 후크, 경로, 스토리지 액세스. 요청 시 샌드박스 런타임에 로드됩니다.

두 파일은 동일한 패키지에 있지만 완전히 다른 환경에서 실행됩니다. 디스크립터는 런타임 컨텍스트를 볼 수 없습니다. 진입점은 astro.config.mjs를 볼 수 없습니다.

my-plugin/
├── src/
│   ├── index.ts          # 디스크립터 — 빌드 시 Vite에서 실행
│   └── sandbox-entry.ts  # 후크, 경로, 스토리지 — 샌드박스 런타임에서 실행
├── package.json
└── tsconfig.json

패키지 설정

  1. 새 디렉토리를 만들고 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/에 빌드합니다.

  2. 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와 같은 스코프가 없는 identrypoint의 스코프가 있는 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, deletelist(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/cloudflaresandbox()를 사용하며, 오늘날 대부분의 사이트에서 사용하는 샌드박스 러너입니다. 다른 플랫폼용 러너가 개발 중입니다. 러너가 구성되지 않았거나 구성된 러너가 현재 플랫폼에서 사용할 수 없다고 보고하는 경우 sandboxed: [] 플러그인은 시작 시 건너뜁니다 — 동일한 플러그인을 인프로세스로 실행하려면 sandboxed: []에서 plugins: []로 이동합니다.

빌드 및 실행

플러그인 디렉토리에서:

pnpm build

사이트 디렉토리에서 플러그인을 링크하거나 설치하고(pnpm add @my-org/plugin-hello 또는 작업 공간 링크) 개발 서버를 시작합니다. 다음에 관리자에서 콘텐츠를 저장할 때 로그에 [hello] Content saved …가 표시되어야 하며 GET /_emdash/api/plugins/plugin-hello/recent는 마지막 10개의 저장 이벤트를 반환해야 합니다.

다음에 읽을 것