├── .editorconfig ├── .gitattributes ├── .gitignore ├── .tool-versions ├── .yarn └── releases │ └── yarn-3.2.1.cjs ├── .yarnrc.yml ├── Cargo.lock ├── Cargo.toml ├── README.md ├── _scripts └── dump-ast-module.mjs ├── cloudflare-hostname-provider ├── package.json └── src │ ├── CloudflareHostnameProvider.ts │ └── index.ts ├── codegen.yml ├── karrotmini-miniapp-manifest ├── Cargo.toml ├── README.md └── src │ ├── lib.rs │ └── v1 │ ├── error.rs │ ├── manifest.rs │ └── mod.rs ├── karrotmini-miniapp-package ├── Cargo.toml ├── README.md ├── src │ ├── lib.rs │ ├── package.rs │ └── result.rs └── tests │ └── data │ ├── invalid_manifest.zip │ ├── no_manifest.zip │ └── valid_manifest.zip ├── minictl ├── cli │ └── package.json └── worker │ ├── .gitignore │ ├── package.json │ ├── src │ └── worker.mjs │ └── wrangler.toml ├── package.json ├── playground-application ├── README.md ├── package.json ├── src │ ├── __generated__ │ │ ├── schema.graphql │ │ ├── schema.ts │ │ └── types.ts │ ├── builtin │ │ ├── BypassResourceAuthorizer.ts │ │ ├── ConsoleReporter.ts │ │ ├── MemoryEventBus.ts │ │ ├── NoopEventBus.ts │ │ ├── NoopReporter.ts │ │ ├── NoopTracer.ts │ │ ├── PlaygroundResourceAuthorizer.test.ts │ │ ├── PlaygroundResourceAuthorizer.ts │ │ ├── VitestAggregator.ts │ │ ├── VitestApplicationContext.ts │ │ ├── VitestBundleStorage.ts │ │ ├── VitestCustomHostRepository.ts │ │ ├── VitestHostnameProvider.ts │ │ └── index.ts │ ├── errors │ │ ├── ResourceLoadingError.ts │ │ └── index.ts │ ├── index.ts │ ├── ports │ │ ├── AppRepository.ts │ │ ├── BundleStorage.ts │ │ ├── BundleUploadRepository.ts │ │ ├── CustomHostRepository.ts │ │ ├── HostnameProvider.ts │ │ ├── UserProfileRepository.ts │ │ └── index.ts │ ├── resolvers │ │ ├── App.ts │ │ ├── AppDeployment.ts │ │ ├── AppManifest.ts │ │ ├── Bundle.ts │ │ ├── BundleTemplate.ts │ │ ├── BundleUpload.ts │ │ ├── CreateAppResult.ts │ │ ├── CreateUserProfileResult.createApp.graphql │ │ ├── CreateUserProfileResult.createApp.ts │ │ ├── CreateUserProfileResult.ts │ │ ├── CreateUserProfileResultCreateAppResult.ts │ │ ├── CustomHost.ts │ │ ├── HostnameProviderInfo.ts │ │ ├── Mutation.createApp.graphql │ │ ├── Mutation.createApp.ts │ │ ├── Mutation.createUserProfile.graphql │ │ ├── Mutation.createUserProfile.ts │ │ ├── Mutation.ts │ │ ├── Node.ts │ │ ├── Query.ts │ │ ├── UserProfile.ts │ │ ├── _createApp.ts │ │ ├── _globalId.ts │ │ ├── _types.graphql │ │ └── index.ts │ ├── runtime │ │ ├── ApplicationContext.ts │ │ ├── EventBus.ts │ │ ├── ExecutionResult.ts │ │ ├── Executor.ts │ │ ├── MutationResult.ts │ │ ├── Mutator.ts │ │ ├── QueryResult.ts │ │ ├── Reporter.ts │ │ ├── Resource.ts │ │ ├── ResourceAuthorizer.ts │ │ ├── Tracer.ts │ │ ├── _common.ts │ │ └── index.ts │ ├── test │ │ └── helpers.ts │ └── usecases │ │ ├── CreateUserApp.generated.ts │ │ ├── CreateUserApp.graphql │ │ ├── CreateUserApp.test.ts │ │ ├── CreateUserProfile.generated.ts │ │ ├── CreateUserProfile.graphql │ │ ├── CreateUserProfile.test.ts │ │ ├── CreateUserProfileWithFirstApp.generated.ts │ │ ├── CreateUserProfileWithFirstApp.graphql │ │ ├── CreateUserProfileWithFirstApp.test.ts │ │ ├── ListUserApps.generated.ts │ │ ├── ListUserApps.graphql │ │ ├── UserProfileById.generated.ts │ │ ├── UserProfileById.graphql │ │ ├── _mutations.ts │ │ ├── _queries.ts │ │ └── index.ts ├── tsconfig.json └── vitest.config.ts ├── playground-bundle-storage ├── .gitignore ├── Cargo.toml ├── README.md ├── package.json ├── src │ ├── controllers │ │ ├── mod.rs │ │ └── upload_bundle_content.rs │ ├── lib.rs │ └── utils.rs └── wrangler.toml ├── playground-cloudflare-adapter ├── README.md ├── _images │ └── playground-cloudflare-infrastructure-overview.png ├── lib │ ├── .gitignore │ ├── package.json │ ├── src │ │ ├── AppRepository.ts │ │ ├── BundleUploadRepository.ts │ │ ├── CustomHostRepository.ts │ │ ├── UserProfileRepository.ts │ │ └── index.ts │ └── tsconfig.json ├── transport │ ├── package.json │ ├── src │ │ ├── client.ts │ │ ├── handler.ts │ │ └── types.ts │ └── tsconfig.json └── worker │ ├── .gitignore │ ├── package.json │ ├── src │ ├── base │ │ ├── DurableObjectAggregatorProtocol.ts │ │ ├── HexaKV.ts │ │ └── Util.ts │ ├── dos │ │ ├── AppDurableObject.ts │ │ ├── BundleUploadDurableObject.ts │ │ ├── CustomHostDurableObject.ts │ │ ├── UserProfileDurableObject.ts │ │ └── index.ts │ ├── repos │ │ ├── AppRepository.ts │ │ ├── BundleUploadRepository.ts │ │ ├── CustomHostRepository.ts │ │ ├── UserProfileRepository.ts │ │ └── index.ts │ └── worker.ts │ ├── tsconfig.json │ ├── wrangler.d.ts │ └── wrangler.toml ├── playground-core ├── .gitattributes ├── README.md ├── package.json ├── src │ ├── entities │ │ ├── .gitattributes │ │ ├── App.test.ts │ │ ├── App.ts │ │ ├── AppIcon.test.ts │ │ ├── AppIcon.ts │ │ ├── AppManifest.ts │ │ ├── Bundle.ts │ │ ├── BundleTemplate.ts │ │ ├── BundleUpload.test.ts │ │ ├── BundleUpload.ts │ │ ├── CustomHost.test.ts │ │ ├── CustomHost.ts │ │ ├── Deployment.ts │ │ ├── HostnameProviderInfo.ts │ │ ├── UserProfile.test.ts │ │ ├── UserProfile.ts │ │ ├── __snapshots__ │ │ │ ├── App.test.ts.snap │ │ │ ├── AppBundleUpload.test.ts.snap │ │ │ ├── BundleUpload.test.ts.snap │ │ │ ├── CustomHost.test.ts.snap │ │ │ └── UserProfile.test.ts.snap │ │ ├── _snapshots.atd │ │ ├── _snapshots.ts │ │ └── index.ts │ ├── errors │ │ ├── AppNameRequiredError.ts │ │ ├── CommandError.ts │ │ ├── ConfigurationError.ts │ │ ├── HostnameAlreadyUsedError.ts │ │ ├── HostnameNotAvailableError.ts │ │ ├── InvariantError.ts │ │ ├── ProtectedDeploymentError.ts │ │ ├── ReservedAppIdError.ts │ │ └── index.ts │ ├── events │ │ ├── AppCreatedFromTemplateEvent.ts │ │ ├── AppDeploymentCreatedEvent.ts │ │ ├── AppDeploymentDeletedEvent.ts │ │ ├── BundleUploadedEvent.ts │ │ ├── CustomHostConnectedEvent.ts │ │ ├── CustomHostDisconnectedEvent.ts │ │ ├── CustomHostProvisionedEvent.ts │ │ ├── UserAppAddedEvent.ts │ │ ├── UserProfileCreatedEvent.ts │ │ ├── UserProfileUpdatedEvent.ts │ │ └── index.ts │ ├── framework │ │ ├── Aggregate.ts │ │ ├── Aggregator.ts │ │ ├── Entity.ts │ │ ├── Event.ts │ │ ├── GUID.ts │ │ ├── Serializable.ts │ │ ├── Snapshot.ts │ │ ├── index.ts │ │ └── utils.ts │ ├── index.ts │ ├── utils.test.ts │ └── utils.ts ├── tsconfig.json └── vitest.config.ts ├── playground-management-api ├── README.md ├── lib │ ├── .gitignore │ ├── package.json │ ├── src │ │ ├── PlaygroundManagementAPI.ts │ │ └── interface │ │ │ ├── CreateApp.ts │ │ │ ├── CreateAppDeployment.ts │ │ │ ├── CreateUserProfile.ts │ │ │ ├── IssueAppCredential.ts │ │ │ ├── IssueUserProfileCredential.ts │ │ │ ├── TransferAppOwnership.ts │ │ │ ├── UpdateAppManifest.ts │ │ │ ├── UploadAppBundle.ts │ │ │ ├── common.ts │ │ │ └── index.ts │ └── tsconfig.json └── worker │ ├── .gitignore │ ├── package.json │ ├── src │ ├── __generated__ │ │ ├── schema.graphql │ │ ├── schema.ts │ │ └── types.ts │ ├── adapters │ │ ├── MiniControl.ts │ │ └── PlaygroundBundleStorage.ts │ ├── authorization.ts │ ├── context.ts │ ├── credential.ts │ ├── gateway.ts │ ├── resolvers │ │ ├── Mutation.issueAppCredential.graphql │ │ ├── Mutation.issueAppCredential.ts │ │ ├── Mutation.issueUserProfileCredential.graphql │ │ ├── Mutation.issueUserProfileCredential.ts │ │ ├── Mutation.ts │ │ └── index.ts │ ├── usecases │ │ ├── IssueAppCredential.generated.ts │ │ ├── IssueAppCredential.graphql │ │ ├── IssueUserProfileCredential.generated.ts │ │ ├── IssueUserProfileCredential.graphql │ │ └── index.ts │ └── worker.ts │ ├── tsconfig.json │ ├── wrangler.d.ts │ └── wrangler.toml ├── playground-remix-ui ├── README.md └── package.json ├── playground-webapp-controller ├── .gitignore ├── Cargo.toml ├── README.md ├── package.json ├── src │ ├── lib.rs │ └── utils.rs └── wrangler.toml ├── tsconfig.json ├── vitest.config.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,jsx,ts,tsx,json,yml,graphql}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .yarn/** linguist-vendored 2 | 3 | **/__generated__/** linguist-generated 4 | **/__snapshots__/** linguist-generated 5 | **/*.generated.* linguist-generated 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | 3 | .DS_Store 4 | .env 5 | *.log 6 | *.tsbuildinfo 7 | node_modules/ 8 | coverage/ 9 | 10 | .yarn/* 11 | !.yarn/patches 12 | !.yarn/plugins 13 | !.yarn/releases 14 | !.yarn/sdks 15 | !.yarn/versions 16 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 18.2.0 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | nmMode: hardlinks-global 3 | 4 | yarnPath: .yarn/releases/yarn-3.2.1.cjs 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "karrotmini-miniapp-manifest", 4 | "karrotmini-miniapp-package", 5 | "playground-bundle-storage", 6 | "playground-webapp-controller", 7 | ] 8 | 9 | [profile.release.package."*"] 10 | opt-level = "s" 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 당근미니 미니앱 플레이그라운드 2 | 3 | [당근미니 미니앱 플레이그라운드](https://playground.karrotmini.dev)는 당근미니 팀이 운영하는 정적 웹 사이트 호스팅 서비스입니다. 4 | 5 | 피드백이나 문의 사항은 [GitHub Discussions](https://github.com/karrotmini/karrotmini-playground/discussions)에 남겨주세요. 6 | -------------------------------------------------------------------------------- /_scripts/dump-ast-module.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | import * as path from 'node:path'; 4 | import * as fs from 'node:fs/promises'; 5 | import { parse } from 'graphql'; 6 | 7 | const { 2: baseFile } = process.argv; 8 | if (!baseFile) { 9 | throw new Error('SDL path is required'); 10 | } 11 | 12 | const dir = path.dirname(baseFile); 13 | const basename = path.basename(baseFile, '.graphql'); 14 | const targetPath = path.join(dir, `${basename}.ts`); 15 | 16 | const source = await fs.readFile( 17 | baseFile, 18 | 'utf-8', 19 | ); 20 | 21 | const ast = parse(source); 22 | 23 | await fs.writeFile( 24 | targetPath, 25 | `/* eslint-disable */ 26 | import type { DocumentNode } from 'graphql'; 27 | 28 | const typeDefs: DocumentNode = JSON.parse(\`${JSON.stringify(ast)}\`); 29 | export default typeDefs;`, 30 | 'utf-8', 31 | ); 32 | -------------------------------------------------------------------------------- /cloudflare-hostname-provider/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@karrotmini/cloudflare-hostname-provider", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@karrotmini/playground-core": "workspace:^" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /cloudflare-hostname-provider/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CloudflareHostnameProvider'; 2 | -------------------------------------------------------------------------------- /karrotmini-miniapp-manifest/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "karrotmini-miniapp-manifest" 3 | version = "0.0.1" 4 | authors = ["Hyeseong Kim "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | serde = { version = "1.0", features = ["derive"] } 9 | serde_json = "1.0" 10 | -------------------------------------------------------------------------------- /karrotmini-miniapp-manifest/README.md: -------------------------------------------------------------------------------- 1 | # Karrotmini Miniapp Manifest Specification 2 | 3 | TBD 4 | -------------------------------------------------------------------------------- /karrotmini-miniapp-manifest/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod v1; 2 | -------------------------------------------------------------------------------- /karrotmini-miniapp-manifest/src/v1/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug)] 2 | pub enum ManifestError { 3 | ParseJsonError(serde_json::Error), 4 | } 5 | 6 | impl From for ManifestError { 7 | fn from(err: serde_json::Error) -> Self { 8 | ManifestError::ParseJsonError(err) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /karrotmini-miniapp-manifest/src/v1/manifest.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | use serde::{Serialize, Deserialize}; 3 | 4 | use crate::v1::error::ManifestError; 5 | 6 | #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] 7 | pub struct Manifest { 8 | pub app_id: String, 9 | pub name: String, 10 | } 11 | 12 | impl FromStr for Manifest { 13 | type Err = ManifestError; 14 | 15 | fn from_str(s: &str) -> Result { 16 | let manifest: Self = serde_json::from_str(s)?; 17 | Ok(manifest) 18 | } 19 | } 20 | 21 | impl TryFrom for Manifest { 22 | type Error = ManifestError; 23 | 24 | fn try_from(value: String) -> Result { 25 | Manifest::from_str(value.as_str()) 26 | } 27 | } 28 | 29 | impl TryFrom> for Manifest { 30 | type Error = ManifestError; 31 | 32 | fn try_from(value: Vec) -> Result { 33 | let manifest = serde_json::from_slice(value.as_slice())?; 34 | Ok(manifest) 35 | } 36 | } 37 | 38 | #[test] 39 | fn v1_manifest_full() { 40 | let json = r#" 41 | { 42 | "app_id": "test-app-id", 43 | "name": "My First App" 44 | } 45 | "#; 46 | 47 | let manifest = Manifest::from_str(&json); 48 | assert!(manifest.is_ok()); 49 | } 50 | 51 | #[test] 52 | fn v1_invalid_manifest_missing_app_id() { 53 | let json = r#" 54 | { 55 | "name": "My First App" 56 | } 57 | "#; 58 | 59 | let manifest = Manifest::from_str(&json); 60 | assert!(manifest.is_err()); 61 | } 62 | 63 | #[test] 64 | fn v1_invalid_manifest_missing_name() { 65 | let json = r#" 66 | { 67 | "app_id": "test" 68 | } 69 | "#; 70 | 71 | let manifest = Manifest::from_str(&json); 72 | assert!(manifest.is_err()); 73 | } 74 | -------------------------------------------------------------------------------- /karrotmini-miniapp-manifest/src/v1/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod manifest; 2 | pub mod error; 3 | -------------------------------------------------------------------------------- /karrotmini-miniapp-package/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "karrotmini-miniapp-package" 3 | version = "0.0.1" 4 | authors = ["Hyeseong Kim "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | zip = { version = "0.6.2", default-features = false, features = ["deflate"] } 9 | 10 | karrotmini-miniapp-manifest = { path = "../karrotmini-miniapp-manifest" } 11 | -------------------------------------------------------------------------------- /karrotmini-miniapp-package/README.md: -------------------------------------------------------------------------------- 1 | # Karrotmini Miniapp Package Specification 2 | 3 | TBD 4 | -------------------------------------------------------------------------------- /karrotmini-miniapp-package/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod result; 2 | pub mod package; 3 | -------------------------------------------------------------------------------- /karrotmini-miniapp-package/src/package.rs: -------------------------------------------------------------------------------- 1 | use std::io::{Cursor, Read}; 2 | 3 | use karrotmini_miniapp_manifest::v1::manifest::Manifest; 4 | use zip::ZipArchive; 5 | 6 | use crate::result::{PackageResult, PackageError}; 7 | 8 | pub type Error = PackageError; 9 | pub type Result = PackageResult; 10 | 11 | pub const MANIFEST_FILENAME: &'static str = "mini.json"; 12 | 13 | #[derive(Debug)] 14 | pub struct Package { 15 | pub manifest: Option, 16 | // FIXME: do it more generic way 17 | inner: ZipArchive>>, 18 | } 19 | 20 | impl Package { 21 | pub fn from_bytes(bytes: Vec) -> Result { 22 | let mut archive = ZipArchive::new(Cursor::new(bytes))?; 23 | let mut buf: Vec = Vec::new(); 24 | 25 | let _ = archive.by_name(MANIFEST_FILENAME).map(|mut file| { 26 | file.read_to_end(&mut buf).unwrap(); 27 | }); 28 | 29 | let manifest = match buf.is_empty() { 30 | true => None, 31 | false => Some(Manifest::try_from(buf)?), 32 | }; 33 | 34 | Ok(Self { 35 | manifest, 36 | inner: archive, 37 | }) 38 | } 39 | 40 | pub fn read_path(&mut self, path: &str) -> Option> { 41 | let name = &path[1..path.len()]; 42 | match self.inner.by_name(name) { 43 | Ok(mut file) => { 44 | let mut buf: Vec = Vec::new(); 45 | file.read_to_end(&mut buf).unwrap(); 46 | Some(buf) 47 | }, 48 | Err(..) => None, 49 | } 50 | } 51 | } 52 | 53 | impl<'a> TryFrom<&'a [u8]> for Package { 54 | type Error = Error; 55 | 56 | fn try_from(bytes: &'a [u8]) -> Result { 57 | Package::from_bytes(Vec::from(bytes)) 58 | } 59 | } 60 | 61 | #[cfg(test)] 62 | mod test { 63 | #[test] 64 | fn extract_valid_manifest() { 65 | use super::Package; 66 | 67 | let mut v = Vec::new(); 68 | v.extend_from_slice(include_bytes!("../tests/data/valid_manifest.zip")); 69 | 70 | let package = Package::from_bytes(v); 71 | assert!(package.is_ok()); 72 | 73 | let manifest = package.unwrap().manifest; 74 | assert!(manifest.is_some()); 75 | } 76 | 77 | #[test] 78 | fn extract_no_manifest() { 79 | use super::Package; 80 | 81 | let mut v = Vec::new(); 82 | v.extend_from_slice(include_bytes!("../tests/data/no_manifest.zip")); 83 | 84 | let package = Package::from_bytes(v); 85 | assert!(package.is_ok()); 86 | 87 | let manifest = package.unwrap().manifest; 88 | assert!(manifest.is_none()); 89 | } 90 | 91 | #[test] 92 | fn invalid_manifest() { 93 | use super::Package; 94 | 95 | let mut v = Vec::new(); 96 | v.extend_from_slice(include_bytes!("../tests/data/invalid_manifest.zip")); 97 | 98 | let package = Package::from_bytes(v); 99 | assert!(package.is_err()); 100 | } 101 | 102 | #[test] 103 | fn invalid_bytes() { 104 | use super::Package; 105 | 106 | let empty_bytes: Vec = Vec::new(); 107 | let package = Package::from_bytes(empty_bytes); 108 | assert!(package.is_err()); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /karrotmini-miniapp-package/src/result.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::fmt; 3 | use std::io; 4 | 5 | use karrotmini_miniapp_manifest::v1::error::ManifestError; 6 | use zip::result::ZipError; 7 | 8 | pub type PackageResult = Result; 9 | 10 | #[derive(Debug)] 11 | pub enum PackageError { 12 | InvalidPackage(Box), 13 | InvalidManifest(ManifestError), 14 | } 15 | 16 | impl fmt::Display for PackageError { 17 | fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { 18 | match self { 19 | PackageError::InvalidPackage(_) => write!(fmt, "invalid package format"), 20 | PackageError::InvalidManifest(_) => write!(fmt, "invalid manifest format"), 21 | } 22 | } 23 | } 24 | 25 | impl Error for PackageError { 26 | fn source(&self) -> Option<&(dyn Error + 'static)> { 27 | None 28 | } 29 | } 30 | 31 | impl From for io::Error { 32 | fn from(err: PackageError) -> io::Error { 33 | io::Error::new(io::ErrorKind::Other, err) 34 | } 35 | } 36 | 37 | impl From for PackageError { 38 | fn from(err: ZipError) -> Self { 39 | PackageError::InvalidPackage(Box::new(err)) 40 | } 41 | } 42 | 43 | impl From for PackageError { 44 | fn from(err: ManifestError) -> Self { 45 | PackageError::InvalidManifest(err) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /karrotmini-miniapp-package/tests/data/invalid_manifest.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karrotmini/playground/c134037f7ac1adb1a16da9a384ce7eaa824b5246/karrotmini-miniapp-package/tests/data/invalid_manifest.zip -------------------------------------------------------------------------------- /karrotmini-miniapp-package/tests/data/no_manifest.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karrotmini/playground/c134037f7ac1adb1a16da9a384ce7eaa824b5246/karrotmini-miniapp-package/tests/data/no_manifest.zip -------------------------------------------------------------------------------- /karrotmini-miniapp-package/tests/data/valid_manifest.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karrotmini/playground/c134037f7ac1adb1a16da9a384ce7eaa824b5246/karrotmini-miniapp-package/tests/data/valid_manifest.zip -------------------------------------------------------------------------------- /minictl/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minictl", 3 | "description": "Management Toolkit for Karrotmini Playground", 4 | "version": "0.0.0-alpha.0" 5 | } 6 | -------------------------------------------------------------------------------- /minictl/worker/.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | -------------------------------------------------------------------------------- /minictl/worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minictl-worker", 3 | "version": "0.0.0", 4 | "private": true, 5 | "devDependencies": { 6 | "wrangler": "^2.0.22" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /minictl/worker/src/worker.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | fetch(request, env) { 3 | const url = new URL(request.url); 4 | const route = `${request.method.toUpperCase()} ${url.pathname}`; 5 | switch (route) { 6 | case 'GET /management_key': { 7 | return new Response(env.MANAGEMENT_KEY, { 8 | status: 200, 9 | headers: { 10 | 'Content-Type': 'text/plain', 11 | }, 12 | }); 13 | } 14 | default: { 15 | return new Response('Operation not permitted', { 16 | status: 400, 17 | }); 18 | } 19 | } 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /minictl/worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "minictl" 2 | workers_dev = true 3 | compatibility_date = "2022-07-21" 4 | compatibility_flags = [ 5 | "url_standard", 6 | ] 7 | account_id = "aad5c82543cd1f267b89737d0f56405e" 8 | 9 | [build] 10 | watch_dir = "src" 11 | command = "mkdir -p dist && cp src/worker.mjs dist/worker.mjs" 12 | 13 | [build.upload] 14 | format = "modules" 15 | dir = "./dist" 16 | main = "./worker.mjs" 17 | 18 | # [secrets] 19 | # MANAGEMENT_KEY 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "project", 3 | "private": true, 4 | "packageManager": "yarn@3.2.1", 5 | "workspaces": [ 6 | "cloudflare-hostname-provider", 7 | "minictl/*", 8 | "playground-core", 9 | "playground-application", 10 | "playground-cloudflare-adapter/*", 11 | "playground-management-api/*", 12 | "playground-remix-ui", 13 | "playground-bundle-storage", 14 | "playground-webapp-controller" 15 | ], 16 | "scripts": { 17 | "codegen": "graphql-codegen", 18 | "test:run": "vitest run --coverage", 19 | "test:dev": "vitest" 20 | }, 21 | "devDependencies": { 22 | "@graphql-codegen/add": "^3.1.1", 23 | "@graphql-codegen/cli": "^2.6.2", 24 | "@graphql-codegen/introspection": "^2.1.1", 25 | "@graphql-codegen/near-operation-file-preset": "^2.2.12", 26 | "@graphql-codegen/schema-ast": "^2.4.1", 27 | "@graphql-codegen/typed-document-node": "^2.2.11", 28 | "@graphql-codegen/typescript": "^2.4.11", 29 | "@graphql-codegen/typescript-operations": "^2.4.0", 30 | "@graphql-codegen/typescript-resolvers": "^2.6.4", 31 | "c8": "^7.11.3", 32 | "typescript": "^4.7.4", 33 | "vite": "^2.9.13", 34 | "vitest": "^0.17.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /playground-application/README.md: -------------------------------------------------------------------------------- 1 | # Karrotmini Playground Service Logic 2 | 3 | WIP 4 | -------------------------------------------------------------------------------- /playground-application/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@karrotmini/playground-application", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "test:run": "vitest run --coverage", 7 | "test:dev": "vitest" 8 | }, 9 | "dependencies": { 10 | "@cometjs/core": "^2.1.0", 11 | "@cometjs/relay-utils": "^1.0.0", 12 | "@envelop/core": "^2.4.0", 13 | "@graphql-tools/schema": "^8.5.0", 14 | "@graphql-typed-document-node/core": "^3.1.1", 15 | "@karrotmini/playground-core": "workspace:^", 16 | "dataloader": "^2.1.0", 17 | "graphql": "^16.5.0", 18 | "graphql-scalars": "^1.17.0" 19 | }, 20 | "devDependencies": { 21 | "c8": "^7.11.3", 22 | "vite": "^2.9.13", 23 | "vitest": "^0.17.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /playground-application/src/__generated__/schema.graphql: -------------------------------------------------------------------------------- 1 | type App implements Node { 2 | canonicalHost: CustomHost! 3 | deployments: [AppDeployment!]! 4 | id: ID! 5 | liveDeployment: AppDeployment 6 | manifest: AppManifest! 7 | } 8 | 9 | type AppDeployment { 10 | bundle: Bundle! 11 | customHost: CustomHost! 12 | delployedAt: DateTime! 13 | name: String! 14 | } 15 | 16 | type AppManifest { 17 | icon: URL! 18 | name: String! 19 | } 20 | 21 | union Bundle = BundleTemplate | BundleUpload 22 | 23 | type BundleTemplate implements Node { 24 | id: ID! 25 | } 26 | 27 | type BundleUpload implements Node { 28 | id: ID! 29 | } 30 | 31 | input CreateAppInput { 32 | appId: String! 33 | name: String! 34 | userProfileId: ID! 35 | } 36 | 37 | type CreateAppResult { 38 | app: App! 39 | customHost: CustomHost! 40 | userProfile: UserProfile! 41 | } 42 | 43 | input CreateUserProfileInput { 44 | _: String 45 | } 46 | 47 | type CreateUserProfileResult { 48 | createApp(input: CreateUserProfileResultCreateAppInput!): CreateUserProfileResultCreateAppResult! 49 | userProfile: UserProfile! 50 | } 51 | 52 | input CreateUserProfileResultCreateAppInput { 53 | appId: String! 54 | name: String! 55 | } 56 | 57 | type CreateUserProfileResultCreateAppResult { 58 | app: App! 59 | customHost: CustomHost! 60 | userProfile: UserProfile! 61 | } 62 | 63 | type CustomHost implements Node { 64 | id: ID! 65 | providerInfo: HostnameProviderInfo! 66 | } 67 | 68 | scalar DateTime 69 | 70 | type HostnameProviderInfo { 71 | healthCheckUrl: URL! 72 | hostname: String! 73 | managementUrl: URL! 74 | url: URL! 75 | } 76 | 77 | type Mutation { 78 | createApp(input: CreateAppInput!): CreateAppResult! 79 | createUserProfile(input: CreateUserProfileInput! = {}): CreateUserProfileResult! 80 | } 81 | 82 | interface Node { 83 | id: ID! 84 | } 85 | 86 | type Query { 87 | node(id: ID!): Node 88 | userProfile(id: ID!): UserProfile 89 | } 90 | 91 | scalar URL 92 | 93 | type UserProfile implements Node { 94 | apps: [App!]! 95 | id: ID! 96 | name: String! 97 | profileImageUrl: URL! 98 | } -------------------------------------------------------------------------------- /playground-application/src/builtin/BypassResourceAuthorizer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UnauthorizedError, 3 | type IResourceAuthorizer, 4 | } from '../runtime/ResourceAuthorizer'; 5 | import * as Resource from '../runtime/Resource'; 6 | 7 | export class BypassResourceAuthorizer implements IResourceAuthorizer { 8 | #denylist = new Set(); 9 | 10 | permit(resource: Resource.T, level: string): void { 11 | // noop 12 | } 13 | 14 | permitByGlobalId(globalId: string, level: string): void { 15 | // noop 16 | } 17 | 18 | prohibit(resource: Resource.T): void { 19 | // noop 20 | } 21 | 22 | prohibitByGlobalId(globalId: string): void { 23 | // noop 24 | } 25 | 26 | guard(resource: Resource.T, level: string): void { 27 | const id = Resource.toGlobalId(resource); 28 | if (this.#denylist.has(id)) { 29 | throw new UnauthorizedError(resource, level); 30 | } 31 | } 32 | 33 | deny(resource: Resource.T) { 34 | const id = Resource.toGlobalId(resource); 35 | this.#denylist.add(id); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /playground-application/src/builtin/ConsoleReporter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type IReporter, 3 | } from '../runtime'; 4 | 5 | export class ConsoleReporter implements IReporter { 6 | debug: IReporter['debug']; 7 | info: IReporter['info']; 8 | warn: IReporter['warn']; 9 | error: IReporter['error']; 10 | captureException: IReporter['captureException']; 11 | 12 | constructor(console: Console) { 13 | this.debug = console.debug.bind(console); 14 | this.info = console.info.bind(console); 15 | this.warn = console.warn.bind(console); 16 | this.error = console.error.bind(console); 17 | this.captureException = console.error.bind(console); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /playground-application/src/builtin/MemoryEventBus.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AnyDomainEvent, 3 | } from '@karrotmini/playground-core/src/framework'; 4 | import { 5 | type IEventBus, 6 | } from '../runtime'; 7 | 8 | export class MemoryEventBus implements IEventBus { 9 | #events: AnyDomainEvent[] = []; 10 | 11 | push(...published: AnyDomainEvent[]): void { 12 | this.#events.push(...published); 13 | } 14 | 15 | pull(): AnyDomainEvent[] { 16 | const events = this.#events.slice(); 17 | this.#events = []; 18 | return events; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /playground-application/src/builtin/NoopEventBus.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AnyDomainEvent, 3 | } from '@karrotmini/playground-core/src/framework'; 4 | import { 5 | type IEventBus, 6 | } from '../runtime'; 7 | 8 | export class NoopEventBus implements IEventBus { 9 | push(...published: AnyDomainEvent[]): void { 10 | // noop 11 | } 12 | 13 | pull(): AnyDomainEvent[] { 14 | return []; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /playground-application/src/builtin/NoopReporter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type IReporter, 3 | } from '../runtime'; 4 | 5 | // eslint-disable-next-line 6 | const noop = () => {}; 7 | 8 | export class NoopReporter implements IReporter { 9 | debug = noop; 10 | info = noop; 11 | warn = noop; 12 | error = noop; 13 | captureException = noop; 14 | } 15 | -------------------------------------------------------------------------------- /playground-application/src/builtin/NoopTracer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ITracer, 3 | type ISpan, 4 | } from '../runtime'; 5 | 6 | export class NoopTracer implements ITracer { 7 | startSpan(_name: string) { 8 | return new NoopSpan(); 9 | } 10 | } 11 | 12 | class NoopSpan implements ISpan { 13 | end() { 14 | // noop 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /playground-application/src/builtin/PlaygroundResourceAuthorizer.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect } from 'vitest'; 2 | 3 | import { 4 | App, 5 | AppID, 6 | } from '@karrotmini/playground-core/src/entities'; 7 | import { 8 | PlaygroundResourceAuthorizer, 9 | AuthorizationError, 10 | UnauthorizedError, 11 | } from './PlaygroundResourceAuthorizer'; 12 | 13 | describe('PlaygroundResourceAuthorizer', test => { 14 | test('unknown resource', () => { 15 | const auth = new PlaygroundResourceAuthorizer(); 16 | const resource = { 17 | typename: 'Unknown', 18 | id: 'TEST', 19 | }; 20 | 21 | expect( 22 | () => auth.permit(resource, 'read'), 23 | ).toThrowError(AuthorizationError); 24 | }); 25 | 26 | test('App - permit read access', () => { 27 | const auth = new PlaygroundResourceAuthorizer(); 28 | 29 | const appId = AppID('TEST'); 30 | const app = new App(appId); 31 | 32 | auth.permit(app, 'read'); 33 | 34 | expect(() => auth.guard(app, 'read')).not.toThrow(); 35 | expect(() => auth.guard(app, 'write')).toThrowError(UnauthorizedError); 36 | expect(() => auth.guard(app, 'admin')).toThrowError(UnauthorizedError); 37 | }); 38 | 39 | test('App - permit write access', () => { 40 | const auth = new PlaygroundResourceAuthorizer(); 41 | 42 | const appId = AppID('TEST'); 43 | const app = new App(appId); 44 | 45 | auth.permit(app, 'write'); 46 | 47 | expect(() => auth.guard(app, 'read')).not.toThrow(); 48 | expect(() => auth.guard(app, 'write')).not.toThrow(); 49 | expect(() => auth.guard(app, 'admin')).toThrowError(UnauthorizedError); 50 | }); 51 | 52 | test('App - permit owner access', () => { 53 | const auth = new PlaygroundResourceAuthorizer(); 54 | 55 | const appId = AppID('TEST'); 56 | const app = new App(appId); 57 | 58 | auth.permit(app, 'owner'); 59 | 60 | expect(() => auth.guard(app, 'read')).not.toThrow(); 61 | expect(() => auth.guard(app, 'write')).not.toThrow(); 62 | expect(() => auth.guard(app, 'owner')).not.toThrow(); 63 | }); 64 | 65 | test('App - contextual access control', () => { 66 | const auth = new PlaygroundResourceAuthorizer(); 67 | 68 | const appId = AppID('TEST'); 69 | const app = new App(appId); 70 | 71 | auth.permit(app, 'read'); 72 | expect(() => auth.guard(app, 'write')).toThrowError(UnauthorizedError); 73 | 74 | auth.permit(app, 'write'); 75 | expect(() => auth.guard(app, 'write')).not.toThrow(); 76 | 77 | auth.prohibit(app); 78 | auth.permit(app, 'read'); 79 | expect(() => auth.guard(app, 'write')).toThrowError(UnauthorizedError); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /playground-application/src/builtin/PlaygroundResourceAuthorizer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AuthorizationError, 3 | UnauthorizedError, 4 | type IResourceAuthorizer, 5 | } from '../runtime/ResourceAuthorizer'; 6 | import * as Resource from '../runtime/Resource'; 7 | 8 | export { 9 | AuthorizationError, 10 | UnauthorizedError, 11 | } from '../runtime/ResourceAuthorizer'; 12 | 13 | export type AuthorizationState = { 14 | [typename: string]: { 15 | [id: string]: { 16 | [level: string]: boolean, 17 | }, 18 | }, 19 | }; 20 | 21 | export type PermissionScheme = { 22 | [typename: string]: { 23 | [level: string]: string[], 24 | }, 25 | }; 26 | 27 | export class PlaygroundResourceAuthorizer implements IResourceAuthorizer { 28 | static Scheme: PermissionScheme = { 29 | UserProfile: { 30 | owner: ['write', 'read'], 31 | write: ['read'], 32 | read: [], 33 | }, 34 | App: { 35 | owner: ['write', 'read'], 36 | write: ['read'], 37 | read: [], 38 | }, 39 | }; 40 | 41 | #state: AuthorizationState; 42 | 43 | constructor(state: AuthorizationState = {}) { 44 | this.#state = state; 45 | } 46 | 47 | toJSON() { 48 | return structuredClone(this.#state); 49 | } 50 | 51 | permit(resource: Resource.T, level: string) { 52 | const { id, typename } = resource; 53 | const scheme = PlaygroundResourceAuthorizer.Scheme[typename]; 54 | if (!scheme?.[level]) { 55 | throw new AuthorizationError(); 56 | } 57 | this.#state[typename] = this.#state[typename] || {}; 58 | this.#state[typename][id] = this.#state[typename][id] || {}; 59 | this.#state[typename][id][level] = true; 60 | for (const child of scheme[level]) { 61 | this.permit(resource, child); 62 | } 63 | } 64 | 65 | permitByGlobalId(globalId: string, level: string) { 66 | const resource = Resource.fromGlobalId(globalId); 67 | this.permit(resource, level); 68 | } 69 | 70 | prohibit({ typename, id }: Resource.T) { 71 | const scheme = PlaygroundResourceAuthorizer.Scheme[typename]; 72 | if (!scheme) { 73 | throw new AuthorizationError(); 74 | } 75 | this.#state[typename] = this.#state[typename] || {}; 76 | this.#state[typename][id] = {}; 77 | } 78 | 79 | prohibitByGlobalId(globalId: string) { 80 | const resource = Resource.fromGlobalId(globalId); 81 | this.prohibit(resource); 82 | } 83 | 84 | guard(resource: Resource.T, level: string): void { 85 | const { typename, id } = resource; 86 | const granted = Boolean(this.#state[typename]?.[id]?.[level]); 87 | if (!granted) { 88 | throw new UnauthorizedError(resource, level); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /playground-application/src/builtin/VitestAggregator.ts: -------------------------------------------------------------------------------- 1 | import { type vi } from 'vitest'; 2 | 3 | import { 4 | type EntityID, 5 | type AnyAggregate, 6 | type Aggregator, 7 | } from '@karrotmini/playground-core/src/framework'; 8 | 9 | import { 10 | type SpyOf, 11 | } from '../test/helpers'; 12 | 13 | export class VitestAggregator implements Aggregator { 14 | newId: SpyOf, 'newId'>; 15 | aggregate: SpyOf, 'aggregate'>; 16 | commit: SpyOf, 'commit'>; 17 | 18 | instances: Map, T>; 19 | 20 | constructor(config: { 21 | vitestUtils: typeof vi, 22 | newId: () => string, 23 | }) { 24 | this.instances = new Map(); 25 | 26 | this.newId = config.vitestUtils.fn(); 27 | this.newId.mockImplementation(() => { 28 | return config.newId() as EntityID; 29 | }); 30 | 31 | this.aggregate = config.vitestUtils.fn(); 32 | this.aggregate.mockImplementation(async id => { 33 | return this.instances.get(id) ?? null; 34 | }); 35 | 36 | this.commit = config.vitestUtils.fn(); 37 | this.commit.mockImplementation(async aggregate => { 38 | const events = aggregate.$pullEvents(); 39 | this.instances.set( 40 | aggregate.id as EntityID, 41 | aggregate, 42 | ); 43 | return events as any; 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /playground-application/src/builtin/VitestApplicationContext.ts: -------------------------------------------------------------------------------- 1 | import { type vi } from 'vitest'; 2 | 3 | import { 4 | type App, 5 | type BundleUpload, 6 | type UserProfile, 7 | } from '@karrotmini/playground-core/src/entities'; 8 | import { 9 | Mutator, 10 | deriveLoaders, 11 | type IEventBus, 12 | type IApplicationContext, 13 | type IReporter, 14 | type ITracer, 15 | } from '../runtime'; 16 | import { 17 | NoopReporter, 18 | BypassResourceAuthorizer, 19 | VitestAggregator, 20 | VitestCustomHostRepository, 21 | VitestBundleStorage, 22 | VitestHostnameProvider, 23 | } from '../builtin'; 24 | 25 | export class VitestApplicationContext implements IApplicationContext { 26 | env: IApplicationContext['env']; 27 | loaders: IApplicationContext['loaders']; 28 | reporter: IApplicationContext['reporter']; 29 | mutator: IApplicationContext['mutator']; 30 | authz: IApplicationContext['authz']; 31 | repos: { 32 | App: VitestAggregator, 33 | BundleUpload: VitestAggregator, 34 | CustomHost: VitestCustomHostRepository, 35 | UserProfile: VitestAggregator, 36 | }; 37 | services: { 38 | bundleStorage: VitestBundleStorage, 39 | hostnameProvider: VitestHostnameProvider, 40 | }; 41 | 42 | constructor(config: { 43 | vitestUtils: typeof vi, 44 | crypto: Crypto, 45 | eventBus: IEventBus, 46 | reporter?: IReporter, 47 | tracer?: ITracer, 48 | }) { 49 | this.env = { 50 | crypto: config.crypto, 51 | vars: { 52 | HOSTNAME_PATTERN: '*.karrotmini.app', 53 | }, 54 | secrets: { 55 | CREDENTIAL_SECRET: 'TEST', 56 | }, 57 | }; 58 | this.authz = new BypassResourceAuthorizer(); 59 | this.reporter = config.reporter ?? new NoopReporter(); 60 | this.services = { 61 | bundleStorage: new VitestBundleStorage(config), 62 | hostnameProvider: new VitestHostnameProvider(config), 63 | }; 64 | const repoConfig = { 65 | newId: () => config.crypto.randomUUID(), 66 | vitestUtils: config.vitestUtils, 67 | }; 68 | this.repos = { 69 | App: new VitestAggregator(repoConfig), 70 | BundleUpload: new VitestAggregator(repoConfig), 71 | CustomHost: new VitestCustomHostRepository(repoConfig), 72 | UserProfile: new VitestAggregator(repoConfig), 73 | }; 74 | this.loaders = deriveLoaders({ repos: this.repos }); 75 | this.mutator = new Mutator({ 76 | eventBus: config.eventBus, 77 | repos: this.repos, 78 | loaders: this.loaders, 79 | }); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /playground-application/src/builtin/VitestBundleStorage.ts: -------------------------------------------------------------------------------- 1 | import { type vi } from 'vitest'; 2 | import { 3 | type IBundleStorage, 4 | } from '../ports'; 5 | import { 6 | type SpyOf, 7 | } from '../test/helpers'; 8 | 9 | export class VitestBundleStorage implements IBundleStorage { 10 | connectBundleHost: SpyOf; 11 | uploadBundleContent: SpyOf; 12 | 13 | constructor(config: { 14 | vitestUtils: typeof vi, 15 | }) { 16 | this.connectBundleHost = config.vitestUtils.fn(); 17 | this.uploadBundleContent = config.vitestUtils.fn(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /playground-application/src/builtin/VitestCustomHostRepository.ts: -------------------------------------------------------------------------------- 1 | import { type vi } from 'vitest'; 2 | import { 3 | type CustomHost, 4 | } from '@karrotmini/playground-core/src/entities'; 5 | import { 6 | type ICustomHostRepository, 7 | } from '../ports'; 8 | import { 9 | type SpyOf, 10 | } from '../test/helpers'; 11 | import { VitestAggregator } from './VitestAggregator'; 12 | 13 | export class VitestCustomHostRepository 14 | extends VitestAggregator 15 | implements ICustomHostRepository 16 | { 17 | writeIndex: SpyOf; 18 | queryByHostname: SpyOf; 19 | 20 | hostnameIndex: Map; 21 | 22 | constructor(config: { 23 | vitestUtils: typeof vi, 24 | newId: () => string, 25 | }) { 26 | super(config); 27 | 28 | this.hostnameIndex = new Map(); 29 | 30 | this.writeIndex = config.vitestUtils.fn(); 31 | this.writeIndex.mockImplementation(async customHost => { 32 | if (customHost.hostname) { 33 | this.hostnameIndex.set(customHost.hostname, customHost); 34 | } 35 | }); 36 | 37 | this.queryByHostname = config.vitestUtils.fn(); 38 | this.queryByHostname.mockImplementation(async hostname => { 39 | return this.hostnameIndex.get(hostname) ?? null; 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /playground-application/src/builtin/VitestHostnameProvider.ts: -------------------------------------------------------------------------------- 1 | import { type vi } from 'vitest'; 2 | import { 3 | type IHostnameProvider, 4 | } from '../ports'; 5 | import { 6 | type SpyOf, 7 | } from '../test/helpers'; 8 | 9 | export class VitestHostnameProvider implements IHostnameProvider { 10 | searchHostname: SpyOf; 11 | createHostname: SpyOf; 12 | checkStatus: SpyOf; 13 | 14 | constructor(config: { 15 | vitestUtils: typeof vi, 16 | }) { 17 | this.searchHostname = config.vitestUtils.fn(); 18 | this.createHostname = config.vitestUtils.fn(); 19 | this.checkStatus = config.vitestUtils.fn(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /playground-application/src/builtin/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ConsoleReporter'; 2 | export * from './NoopReporter'; 3 | export * from './MemoryEventBus'; 4 | export * from './NoopEventBus'; 5 | export * from './NoopTracer'; 6 | export * from './BypassResourceAuthorizer'; 7 | export * from './PlaygroundResourceAuthorizer'; 8 | export * from './VitestAggregator'; 9 | export * from './VitestCustomHostRepository'; 10 | export * from './VitestApplicationContext'; 11 | export * from './VitestBundleStorage'; 12 | export * from './VitestHostnameProvider'; 13 | -------------------------------------------------------------------------------- /playground-application/src/errors/ResourceLoadingError.ts: -------------------------------------------------------------------------------- 1 | import { type Resource } from '../runtime'; 2 | 3 | export class ResourceLoadingError extends Error { 4 | constructor(resource: Resource.T) { 5 | super(`Failed to load ${resource.typename}(${resource.id})`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /playground-application/src/errors/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | InvariantError, 3 | } from '@karrotmini/playground-core/src/errors'; 4 | 5 | export * from './ResourceLoadingError'; 6 | -------------------------------------------------------------------------------- /playground-application/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './runtime'; 2 | export * from './ports'; 3 | export * from './builtin'; 4 | export * from './usecases'; 5 | export * from './errors'; 6 | -------------------------------------------------------------------------------- /playground-application/src/ports/AppRepository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Aggregator, 3 | } from '@karrotmini/playground-core/src/framework'; 4 | import { 5 | type App, 6 | } from '@karrotmini/playground-core/src/entities'; 7 | 8 | export interface IAppRepository extends Aggregator { 9 | } 10 | -------------------------------------------------------------------------------- /playground-application/src/ports/BundleStorage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Bundle, 3 | type CustomHost, 4 | } from '@karrotmini/playground-core/src/entities'; 5 | 6 | // FIXME: 웹앱 컨트롤러 인터페이스로 기능 위임하기 7 | export interface IBundleStorage { 8 | connectBundleHost(props: { 9 | bundle: Bundle, 10 | customHost: CustomHost, 11 | }): Promise; 12 | 13 | uploadBundleContent(props: { 14 | bundle: Bundle, 15 | content: ReadableStream, 16 | }): Promise; 17 | } 18 | -------------------------------------------------------------------------------- /playground-application/src/ports/BundleUploadRepository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Aggregator, 3 | } from '@karrotmini/playground-core/src/framework'; 4 | import { 5 | type BundleUpload, 6 | } from '@karrotmini/playground-core/src/entities'; 7 | 8 | export interface IBundleUploadRepository extends Aggregator { 9 | } 10 | -------------------------------------------------------------------------------- /playground-application/src/ports/CustomHostRepository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Aggregator, 3 | } from '@karrotmini/playground-core/src/framework'; 4 | import { 5 | type CustomHost, 6 | } from '@karrotmini/playground-core/src/entities'; 7 | 8 | export interface ICustomHostRepository extends Aggregator { 9 | queryByHostname(hostname: string): Promise; 10 | writeIndex(customHost: CustomHost): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /playground-application/src/ports/HostnameProvider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type HostnameProviderInfo, 3 | } from '@karrotmini/playground-core/src/entities'; 4 | 5 | export type HostnameStatus = ( 6 | | 'active' 7 | | 'pending_validation' 8 | | 'not_available' 9 | ); 10 | 11 | // FIXME: 웹앱 컨트롤러 인터페이스로 기능 위임하기 12 | export interface IHostnameProvider { 13 | searchHostname(props: { 14 | hostname: string, 15 | }): Promise; 16 | 17 | createHostname(props: { 18 | hostname: string, 19 | }): Promise; 20 | 21 | checkStatus(props: { 22 | providerInfo: HostnameProviderInfo, 23 | }): Promise; 24 | } 25 | -------------------------------------------------------------------------------- /playground-application/src/ports/UserProfileRepository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Aggregator, 3 | } from '@karrotmini/playground-core/src/framework'; 4 | import { 5 | type UserProfile, 6 | } from '@karrotmini/playground-core/src/entities'; 7 | 8 | export interface IUserProfileRepository extends Aggregator { 9 | } 10 | -------------------------------------------------------------------------------- /playground-application/src/ports/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AppRepository'; 2 | export * from './BundleStorage'; 3 | export * from './BundleUploadRepository'; 4 | export * from './CustomHostRepository'; 5 | export * from './HostnameProvider'; 6 | export * from './UserProfileRepository'; 7 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/App.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ResourceLoadingError, 3 | } from '@karrotmini/playground-application/src/errors'; 4 | import { 5 | type AppResolvers, 6 | } from '@karrotmini/playground-application/src/__generated__/types'; 7 | 8 | import { globalIdResolver } from './_globalId'; 9 | 10 | export const App: AppResolvers = { 11 | id: globalIdResolver, 12 | manifest(app) { 13 | return app.manifest; 14 | }, 15 | liveDeployment(app) { 16 | return app.liveDeployment; 17 | }, 18 | deployments(app) { 19 | return Object.values(app.deployments) 20 | .sort((a, b) => b.deployedAt - a.deployedAt); 21 | }, 22 | async canonicalHost(app, _args, { application }) { 23 | const customHost = await application.loaders.CustomHost.load(app.customHostId); 24 | if (!customHost) { 25 | throw new ResourceLoadingError({ typename: 'CustomHost', id: app.customHostId }); 26 | } 27 | return customHost; 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/AppDeployment.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BundleTemplate, 3 | } from '@karrotmini/playground-core/src/entities'; 4 | import { 5 | ResourceLoadingError, 6 | } from '@karrotmini/playground-application/src/errors'; 7 | import { 8 | type AppDeploymentResolvers, 9 | } from '@karrotmini/playground-application/src/__generated__/types'; 10 | 11 | export const AppDeployment: AppDeploymentResolvers = { 12 | name(deployment) { 13 | return deployment.name; 14 | }, 15 | delployedAt(deployment) { 16 | return deployment.deployedAt; 17 | }, 18 | async customHost(deployment, _args, { application }) { 19 | const customHost = await application.loaders.CustomHost.load(deployment.customHostId); 20 | if (!customHost) { 21 | throw new ResourceLoadingError({ typename: 'CustomHost', id: deployment.customHostId }); 22 | } 23 | return customHost; 24 | }, 25 | async bundle(deployment, _args, { application }) { 26 | switch (deployment.bundle.type) { 27 | case 'template': { 28 | return new BundleTemplate(deployment.bundle.id); 29 | } 30 | 31 | case 'upload': { 32 | const upload = await application.loaders.BundleUpload.load(deployment.bundle.id); 33 | if (!upload) { 34 | throw new ResourceLoadingError({ typename: 'BundleUpload', id: deployment.bundle.id }); 35 | } 36 | return upload; 37 | } 38 | } 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/AppManifest.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AppManifestResolvers, 3 | } from '@karrotmini/playground-application/src/__generated__/types'; 4 | 5 | export const AppManifest: AppManifestResolvers = { 6 | name(manifest) { 7 | return manifest.name; 8 | }, 9 | icon(manifest) { 10 | return manifest.icon.toString(); 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/Bundle.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BundleUpload, 3 | BundleTemplate, 4 | } from '@karrotmini/playground-core/src/entities'; 5 | import { 6 | InvariantError 7 | } from '@karrotmini/playground-core/src/errors'; 8 | import { 9 | type BundleResolvers, 10 | } from '@karrotmini/playground-application/src/__generated__/types'; 11 | 12 | export const Bundle: BundleResolvers = { 13 | __resolveType(node) { 14 | if (node instanceof BundleUpload) { 15 | return 'BundleUpload'; 16 | } 17 | if (node instanceof BundleTemplate) { 18 | return 'BundleTemplate'; 19 | } 20 | throw new InvariantError('invalid bundle type'); 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/BundleTemplate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type BundleTemplateResolvers, 3 | } from '@karrotmini/playground-application/src/__generated__/types'; 4 | import { globalIdResolver } from './_globalId'; 5 | 6 | export const BundleTemplate: BundleTemplateResolvers = { 7 | id: globalIdResolver, 8 | }; 9 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/BundleUpload.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type BundleUploadResolvers, 3 | } from '@karrotmini/playground-application/src/__generated__/types'; 4 | import { globalIdResolver } from './_globalId'; 5 | 6 | export const BundleUpload: BundleUploadResolvers = { 7 | id: globalIdResolver, 8 | }; 9 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/CreateAppResult.ts: -------------------------------------------------------------------------------- 1 | import { defaultFieldResolver } from 'graphql'; 2 | import { 3 | type App, 4 | } from '@karrotmini/playground-core/src/entities'; 5 | 6 | export type CreateAppResultRoot = { 7 | app: App, 8 | }; 9 | 10 | export const app = defaultFieldResolver; 11 | export const customHost = defaultFieldResolver; 12 | export const userProfile = defaultFieldResolver; 13 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/CreateUserProfileResult.createApp.graphql: -------------------------------------------------------------------------------- 1 | input CreateUserProfileResultCreateAppInput { 2 | appId: String! 3 | name: String! 4 | } 5 | 6 | type CreateUserProfileResultCreateAppResult { 7 | customHost: CustomHost! 8 | userProfile: UserProfile! 9 | app: App! 10 | } 11 | 12 | type CreateUserProfileResult { 13 | createApp( 14 | input: CreateUserProfileResultCreateAppInput! 15 | ): CreateUserProfileResultCreateAppResult! 16 | } 17 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/CreateUserProfileResult.createApp.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CreateUserProfileResultResolvers, 3 | } from '@karrotmini/playground-application/src/__generated__/types'; 4 | 5 | import { 6 | createApp as _createApp, 7 | } from './_createApp'; 8 | 9 | export const createApp: CreateUserProfileResultResolvers['createApp'] = async ( 10 | root, 11 | args, 12 | context, 13 | ) => { 14 | const { 15 | application, 16 | } = context; 17 | 18 | const result = await _createApp( 19 | root, 20 | args, 21 | context, 22 | ); 23 | 24 | return application.mutator.commit(result); 25 | }; 26 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/CreateUserProfileResult.ts: -------------------------------------------------------------------------------- 1 | import { defaultFieldResolver } from 'graphql'; 2 | import { 3 | type UserProfile, 4 | } from '@karrotmini/playground-core/src/entities'; 5 | 6 | export type CreateUserProfileResultRoot = { 7 | userProfile: UserProfile, 8 | }; 9 | 10 | export const userProfile = defaultFieldResolver; 11 | 12 | export * from './CreateUserProfileResult.createApp'; 13 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/CreateUserProfileResultCreateAppResult.ts: -------------------------------------------------------------------------------- 1 | import { defaultFieldResolver } from 'graphql'; 2 | import { 3 | type App, 4 | type CustomHost, 5 | type UserProfile, 6 | } from '@karrotmini/playground-core/src/entities'; 7 | 8 | export type CreateUserProfileResultCreateAppResultRoot = { 9 | app: App, 10 | customHost: CustomHost, 11 | userProfile: UserProfile, 12 | }; 13 | 14 | export const app = defaultFieldResolver; 15 | export const customHost = defaultFieldResolver; 16 | export const userProfile = defaultFieldResolver; 17 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/CustomHost.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CustomHostResolvers, 3 | } from '@karrotmini/playground-application/src/__generated__/types'; 4 | 5 | import { globalIdResolver } from './_globalId'; 6 | 7 | export const CustomHost: CustomHostResolvers = { 8 | id: globalIdResolver, 9 | providerInfo(customHost) { 10 | return customHost.providerInfo; 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/HostnameProviderInfo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type HostnameProviderInfoResolvers, 3 | } from '@karrotmini/playground-application/src/__generated__/types'; 4 | 5 | export const HostnameProviderInfo: HostnameProviderInfoResolvers = { 6 | url(provierInfo) { 7 | return `https://${provierInfo.hostname}`; 8 | }, 9 | hostname(info) { 10 | return info.hostname; 11 | }, 12 | managementUrl(info) { 13 | return info.managementUrl.toString(); 14 | }, 15 | healthCheckUrl(info) { 16 | return info.healthCheckUrl.toString(); 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/Mutation.createApp.graphql: -------------------------------------------------------------------------------- 1 | input CreateAppInput { 2 | userProfileId: ID! 3 | appId: String! 4 | name: String! 5 | } 6 | 7 | type CreateAppResult { 8 | customHost: CustomHost! 9 | userProfile: UserProfile! 10 | app: App! 11 | } 12 | 13 | type Mutation { 14 | createApp( 15 | input: CreateAppInput! 16 | ): CreateAppResult! 17 | } 18 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/Mutation.createApp.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UserProfileID, 3 | } from '@karrotmini/playground-core/src/entities'; 4 | import { 5 | Resource, 6 | } from '@karrotmini/playground-application/src/runtime'; 7 | import { 8 | ResourceLoadingError, 9 | } from '@karrotmini/playground-application/src/errors'; 10 | import { 11 | type MutationResolvers, 12 | } from '@karrotmini/playground-application/src/__generated__/types'; 13 | 14 | import { createApp as _createApp } from './_createApp'; 15 | 16 | export const createApp: MutationResolvers['createApp'] = async ( 17 | _root, 18 | args, 19 | context, 20 | ) => { 21 | const { 22 | application, 23 | } = context; 24 | const resource = Resource.fromGlobalId(args.input.userProfileId); 25 | application.authz.guard(resource, 'write'); 26 | 27 | const userProfileId = UserProfileID(resource.id); 28 | const userProfile = await application.loaders.UserProfile.load(userProfileId); 29 | if (!userProfile) { 30 | throw new ResourceLoadingError(resource); 31 | } 32 | 33 | const result = await _createApp( 34 | { userProfile }, 35 | args, 36 | context, 37 | ); 38 | 39 | return application.mutator.commit(result); 40 | }; 41 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/Mutation.createUserProfile.graphql: -------------------------------------------------------------------------------- 1 | input CreateUserProfileInput { 2 | _: String 3 | } 4 | 5 | type CreateUserProfileResult { 6 | userProfile: UserProfile! 7 | } 8 | 9 | type Mutation { 10 | createUserProfile( 11 | input: CreateUserProfileInput! = {} 12 | ): CreateUserProfileResult! 13 | } 14 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/Mutation.createUserProfile.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UserProfile, 3 | } from '@karrotmini/playground-core/src/entities'; 4 | import { 5 | type MutationResolvers, 6 | } from '@karrotmini/playground-application/src/__generated__/types'; 7 | 8 | export const createUserProfile: MutationResolvers['createUserProfile'] = async ( 9 | _root, 10 | _args, 11 | { application }, 12 | ) => { 13 | const id = await application.repos.UserProfile.newId(); 14 | const userProfile = UserProfile.create({ id }); 15 | 16 | return application.mutator.commit({ 17 | userProfile, 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/Mutation.ts: -------------------------------------------------------------------------------- 1 | export * from './Mutation.createApp'; 2 | export * from './Mutation.createUserProfile'; 3 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/Node.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type NodeResolvers, 3 | } from '@karrotmini/playground-application/src/__generated__/types'; 4 | 5 | import { globalIdResolver } from './_globalId'; 6 | 7 | export const Node: NodeResolvers = { 8 | __resolveType(node) { 9 | return node.typename; 10 | }, 11 | id: globalIdResolver, 12 | }; 13 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/Query.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UserProfileID, 3 | } from '@karrotmini/playground-core/src/entities'; 4 | import { 5 | type QueryResolvers, 6 | } from '@karrotmini/playground-application/src/__generated__/types'; 7 | import { 8 | Resource, 9 | type RepositoryName, 10 | } from '@karrotmini/playground-application/src/runtime'; 11 | 12 | export const Query: QueryResolvers = { 13 | node(_root, args, { application }) { 14 | const { typename, id } = Resource.fromGlobalId(args.id); 15 | const loader = application.loaders[typename as RepositoryName]; 16 | return loader.load(id as any); 17 | }, 18 | userProfile(_root, args, { application }) { 19 | const { id } = Resource.fromGlobalId(args.id); 20 | return application.loaders.UserProfile.load(UserProfileID(id)); 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/UserProfile.ts: -------------------------------------------------------------------------------- 1 | import { Condition } from '@cometjs/core'; 2 | import { 3 | type UserProfileResolvers, 4 | } from '@karrotmini/playground-application/src/__generated__/types'; 5 | 6 | import { globalIdResolver } from './_globalId'; 7 | 8 | export const UserProfile: UserProfileResolvers = { 9 | id: globalIdResolver, 10 | name(userProfile) { 11 | return userProfile.name; 12 | }, 13 | profileImageUrl(userProfile) { 14 | return userProfile.profileImageUrl.toString(); 15 | }, 16 | async apps(userProfile, _args, { application }) { 17 | const apps = await Promise.all( 18 | userProfile.appIds.map(appId => application.loaders.App.load(appId)), 19 | ); 20 | return apps.filter(Condition.isTruthy); 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/_createApp.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | BundleTemplate, 4 | AppIcon, 5 | AppManifest, 6 | CustomHost, 7 | UserProfile, 8 | } from '@karrotmini/playground-core/src/entities'; 9 | import { 10 | HostnameAlreadyUsedError, 11 | HostnameNotAvailableError, 12 | } from '@karrotmini/playground-core/src/errors'; 13 | import { 14 | createRandomColor, 15 | } from '@karrotmini/playground-core/src/utils'; 16 | import { 17 | type IExecutorContext, 18 | } from '@karrotmini/playground-application/src/runtime'; 19 | 20 | export async function createApp( 21 | root: { 22 | userProfile: UserProfile, 23 | }, 24 | args: { 25 | input: { 26 | name: string, 27 | appId: string, 28 | }, 29 | }, 30 | { 31 | application: { 32 | env, 33 | repos, 34 | services, 35 | }, 36 | }: IExecutorContext, 37 | ) { 38 | const { userProfile } = root; 39 | 40 | const manifestAppId = args.input.appId; 41 | const hostname = env.vars.HOSTNAME_PATTERN.replace('*', manifestAppId); 42 | const appId = await repos.App.newId(); 43 | 44 | let customHost: CustomHost | null = await repos.CustomHost.queryByHostname(hostname); 45 | if (customHost?.connectedApp) { 46 | throw new HostnameAlreadyUsedError(hostname); 47 | } else if (!customHost) { 48 | const customHostId = await repos.CustomHost.newId(); 49 | let providerInfo = await services.hostnameProvider.searchHostname({ hostname }); 50 | if (!providerInfo) { 51 | providerInfo = await services.hostnameProvider.createHostname({ hostname }); 52 | } 53 | if (!providerInfo) { 54 | throw new HostnameNotAvailableError(hostname); 55 | } 56 | customHost = CustomHost.createWithProviderInfo({ 57 | id: customHostId, 58 | providerInfo, 59 | }); 60 | } 61 | if (!customHost) { 62 | throw new HostnameNotAvailableError(hostname); 63 | } 64 | 65 | const appIcon = AppIcon.createFallbackSVG({ 66 | size: 100, 67 | text: args.input.name, 68 | color: [ 69 | createRandomColor(Math.random), 70 | createRandomColor(Math.random), 71 | ], 72 | }); 73 | const manifest = new AppManifest({ 74 | appId: manifestAppId, 75 | name: args.input.name, 76 | icon: appIcon.toString(), 77 | }); 78 | const template = BundleTemplate.centeringDiv(); 79 | 80 | const { app, deployment } = App.bootstrapFromTemplate({ 81 | id: appId, 82 | manifest, 83 | ownerId: userProfile.id, 84 | customHostId: customHost.id, 85 | templateId: template.id, 86 | }); 87 | 88 | userProfile.addApp({ appId }); 89 | customHost.connect({ appId, deploymentName: deployment.name }); 90 | 91 | return { 92 | userProfile, 93 | customHost, 94 | app, 95 | }; 96 | } 97 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/_globalId.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Resource, 3 | } from '@karrotmini/playground-application/src/runtime'; 4 | import { 5 | type NodeResolvers, 6 | } from '@karrotmini/playground-application/src/__generated__/types'; 7 | 8 | export const globalIdResolver: NodeResolvers['id'] = root => { 9 | return Resource.toGlobalId({ typename: root.typename, id: root.id }); 10 | }; 11 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/_types.graphql: -------------------------------------------------------------------------------- 1 | scalar URL 2 | scalar DateTime 3 | 4 | schema { 5 | query: Query 6 | mutation: Mutation 7 | } 8 | 9 | type Query { 10 | node(id: ID!): Node 11 | userProfile(id: ID!): UserProfile 12 | } 13 | 14 | interface Node { 15 | id: ID! 16 | } 17 | 18 | type App implements Node { 19 | id: ID! 20 | manifest: AppManifest! 21 | canonicalHost: CustomHost! 22 | liveDeployment: AppDeployment 23 | deployments: [AppDeployment!]! 24 | } 25 | 26 | type AppManifest { 27 | name: String! 28 | icon: URL! 29 | } 30 | 31 | type AppDeployment { 32 | name: String! 33 | bundle: Bundle! 34 | customHost: CustomHost! 35 | delployedAt: DateTime! 36 | } 37 | 38 | union Bundle = BundleUpload | BundleTemplate 39 | 40 | type BundleUpload implements Node { 41 | id: ID! 42 | } 43 | 44 | type BundleTemplate implements Node { 45 | id: ID! 46 | } 47 | 48 | type CustomHost implements Node { 49 | id: ID! 50 | providerInfo: HostnameProviderInfo! 51 | } 52 | 53 | type HostnameProviderInfo { 54 | url: URL! 55 | hostname: String! 56 | healthCheckUrl: URL! 57 | managementUrl: URL! 58 | } 59 | 60 | type UserProfile implements Node { 61 | id: ID! 62 | name: String! 63 | apps: [App!]! 64 | profileImageUrl: URL! 65 | } 66 | -------------------------------------------------------------------------------- /playground-application/src/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | URLResolver as URL, 3 | DateTimeResolver as DateTime, 4 | } from 'graphql-scalars'; 5 | 6 | export * as Mutation from './Mutation'; 7 | export * as CreateAppResult from './CreateAppResult'; 8 | export * as CreateUserProfileResult from './CreateUserProfileResult'; 9 | export * as CreateUserProfileResultCreateAppResult from './CreateUserProfileResultCreateAppResult'; 10 | 11 | export * from './Node'; 12 | export * from './Query'; 13 | export * from './App'; 14 | export * from './AppDeployment'; 15 | export * from './AppManifest'; 16 | export * from './Bundle'; 17 | export * from './BundleUpload'; 18 | export * from './BundleTemplate'; 19 | export * from './CustomHost'; 20 | export * from './HostnameProviderInfo'; 21 | export * from './UserProfile'; 22 | -------------------------------------------------------------------------------- /playground-application/src/runtime/ApplicationContext.ts: -------------------------------------------------------------------------------- 1 | import DataLoader from 'dataloader'; 2 | import { 3 | type AnyAggregate, 4 | } from '@karrotmini/playground-core/src/framework'; 5 | 6 | import { 7 | type Repositories, 8 | type RepositoryLoaders, 9 | type Services, 10 | } from './_common'; 11 | 12 | import { type IEventBus } from './EventBus'; 13 | import { type IResourceAuthorizer } from './ResourceAuthorizer'; 14 | import { Mutator, type IMutator } from './Mutator'; 15 | import { type IReporter } from './Reporter'; 16 | 17 | export type ApplicationEnvironment = Readonly<{ 18 | crypto: Crypto, 19 | vars: Readonly<{ 20 | HOSTNAME_PATTERN: string, 21 | }>, 22 | secrets: Readonly<{ 23 | }>, 24 | }>; 25 | 26 | export interface IApplicationContext { 27 | env: ApplicationEnvironment; 28 | services: Services; 29 | repos: Repositories; 30 | loaders: RepositoryLoaders; 31 | mutator: IMutator; 32 | reporter: IReporter; 33 | authz: IResourceAuthorizer; 34 | } 35 | 36 | export function makeApplicationContext(config: { 37 | env: ApplicationEnvironment, 38 | repos: Repositories, 39 | services: Services, 40 | eventBus: IEventBus, 41 | reporter: IReporter, 42 | authz: IResourceAuthorizer, 43 | aggregateCache?: Map>, 44 | }): Readonly { 45 | const loaders = deriveLoaders({ repos: config.repos }); 46 | const mutator = new Mutator({ 47 | eventBus: config.eventBus, 48 | repos: config.repos, 49 | loaders, 50 | }); 51 | return Object.freeze({ 52 | ...config, 53 | loaders, 54 | mutator, 55 | }); 56 | } 57 | 58 | export function deriveLoaders(config: { 59 | repos: Repositories, 60 | aggregateCache?: Map>, 61 | }): RepositoryLoaders { 62 | const makeCacheKeyFn = (typename: string) => (key: string) => { 63 | return `${typename}:${key}`; 64 | }; 65 | return Object.fromEntries( 66 | Object.entries(config.repos) 67 | .map(([name, repo]) => [ 68 | name, 69 | new DataLoader(keys => { 70 | return Promise.all( 71 | keys.map(key => { 72 | try { 73 | return repo.aggregate(key as any); 74 | } catch (e: any) { 75 | return e as Error; 76 | } 77 | }), 78 | ); 79 | }, { 80 | cache: true, 81 | cacheKeyFn: makeCacheKeyFn(name), 82 | cacheMap: config.aggregateCache || new Map(), 83 | }), 84 | ] as const), 85 | ) as RepositoryLoaders; 86 | } 87 | -------------------------------------------------------------------------------- /playground-application/src/runtime/EventBus.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AnyDomainEvent, 3 | } from '@karrotmini/playground-core/src/framework'; 4 | 5 | export interface IEventBus { 6 | push(...event: Event[]): void; 7 | // subscribe(): Generator; 8 | } 9 | -------------------------------------------------------------------------------- /playground-application/src/runtime/ExecutionResult.ts: -------------------------------------------------------------------------------- 1 | import { type GraphQLError } from 'graphql'; 2 | 3 | export type ExecutionResult> = ( 4 | | { data: ResponseData, errors: null } 5 | | { data: null, errors: readonly GraphQLError[] } 6 | ); 7 | -------------------------------------------------------------------------------- /playground-application/src/runtime/Executor.ts: -------------------------------------------------------------------------------- 1 | import { type GraphQLSchema } from 'graphql'; 2 | import { type TypedDocumentNode } from '@graphql-typed-document-node/core'; 3 | import { makeExecutableSchema } from '@graphql-tools/schema'; 4 | import { 5 | envelop, 6 | useSchema, 7 | type ExecuteFunction, 8 | type PluginOrDisabledPlugin, 9 | } from '@envelop/core'; 10 | 11 | import typeDefs from '../__generated__/schema'; 12 | import { type IApplicationContext } from '../runtime'; 13 | import * as resolvers from '../resolvers'; 14 | 15 | import { type ExecutionResult } from './ExecutionResult'; 16 | 17 | export interface IExecutorContext { 18 | application: IApplicationContext, 19 | } 20 | 21 | export interface IExecutor { 22 | bindContext(context: Context): void; 23 | execute< 24 | ResponseData extends Record, 25 | RequestVariables extends Record, 26 | >( 27 | document: TypedDocumentNode, 28 | variables: RequestVariables, 29 | ): Promise>; 30 | } 31 | 32 | type ConfigType = T extends (config: infer U) => any ? U : never; 33 | type TypeDefsConfig = ConfigType['typeDefs']; 34 | type ResolversConfig = ConfigType['resolvers']; 35 | 36 | export class Executor implements IExecutor { 37 | #context: Context; 38 | #schema: GraphQLSchema; 39 | #execute: ExecuteFunction; 40 | 41 | constructor(config: { 42 | context: Context, 43 | plugins?: PluginOrDisabledPlugin[], 44 | additionalTypeDefs?: TypeDefsConfig, 45 | additionalResolvers?: ResolversConfig, 46 | }) { 47 | this.#context = config.context; 48 | this.#schema = makeExecutableSchema({ 49 | typeDefs: [typeDefs, config.additionalTypeDefs] 50 | .flatMap(v => v || []), 51 | resolvers: [resolvers, config.additionalResolvers] 52 | .flatMap(v => v || []) as ResolversConfig, 53 | resolverValidationOptions: { 54 | requireResolversForAllFields: 'error', 55 | requireResolversForResolveType: 'error', 56 | }, 57 | }); 58 | const getEnveloped = envelop({ 59 | plugins: [ 60 | useSchema(this.#schema), 61 | ...config.plugins || [], 62 | ], 63 | }); 64 | const { execute } = getEnveloped(); 65 | this.#execute = execute; 66 | } 67 | 68 | get context() { 69 | return this.#context; 70 | } 71 | 72 | get schema() { 73 | return this.#schema; 74 | } 75 | 76 | bindContext(context: Context) { 77 | this.#context = context; 78 | } 79 | 80 | async execute< 81 | ResponseData extends Record, 82 | RequestVariables extends Record, 83 | >( 84 | document: TypedDocumentNode, 85 | variables: RequestVariables, 86 | ): Promise> { 87 | const { data, errors } = await this.#execute({ 88 | schema: this.#schema, 89 | document, 90 | variableValues: variables, 91 | contextValue: this.#context, 92 | }); 93 | if (errors) { 94 | return { 95 | data: null, 96 | errors, 97 | }; 98 | } else { 99 | return { 100 | data: data as any, 101 | errors: null, 102 | }; 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /playground-application/src/runtime/MutationResult.ts: -------------------------------------------------------------------------------- 1 | import { InvariantError } from '@karrotmini/playground-core/src/errors'; 2 | import { type ExecutionResult } from './ExecutionResult'; 3 | 4 | export type T> = ( 5 | | ExecutionResult 6 | ); 7 | 8 | export function map< 9 | ResponseData extends Record, 10 | RData, 11 | RError = null, 12 | >( 13 | result: T, 14 | map: ( 15 | | ((data: ResponseData) => RData) 16 | | { 17 | data: ((data: ResponseData) => RData), 18 | error: ((error: Error) => RError), 19 | } 20 | ), 21 | ): RData | RError { 22 | if (typeof map === 'function') { 23 | if (result.data) { 24 | return map(result.data); 25 | } 26 | if (result.errors) { 27 | return null as unknown as RError; 28 | } 29 | } 30 | 31 | if (map && typeof map === 'object') { 32 | if (result.data) { 33 | return map.data(result.data); 34 | } 35 | if (result.errors?.[0].originalError) { 36 | return map.error(result.errors[0].originalError); 37 | } 38 | } 39 | 40 | throw new InvariantError('invalid mapExecutionResult params'); 41 | } 42 | 43 | export function unwrap< 44 | ResponseData extends Record, 45 | >( 46 | result: T, 47 | ): ResponseData | null { 48 | return map(result, { 49 | data: result => result, 50 | error: () => null, 51 | }); 52 | } 53 | 54 | export function unwrapError< 55 | ResponseData extends Record, 56 | >( 57 | result: T, 58 | ): Error | null { 59 | return map(result, { 60 | data: () => null, 61 | error: result => result, 62 | }); 63 | } 64 | -------------------------------------------------------------------------------- /playground-application/src/runtime/Mutator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Aggregate, 3 | type AnyDomainEvent, 4 | } from '@karrotmini/playground-core/src/framework'; 5 | import { 6 | CommandError, 7 | ConfigurationError, 8 | } from '@karrotmini/playground-core/src/errors'; 9 | 10 | import { 11 | type Repository, 12 | type Repositories, 13 | type RepositoryName, 14 | type RepositoryLoader, 15 | type RepositoryLoaders, 16 | } from './_common'; 17 | import { type IEventBus } from './EventBus'; 18 | 19 | export interface IMutator { 20 | commit( 21 | aggregateMap: AggregateMap, 22 | ): Promise; 23 | } 24 | 25 | export class Mutator implements IMutator { 26 | readonly #eventBus: IEventBus | null; 27 | readonly #repos: Repositories; 28 | readonly #loaders: RepositoryLoaders; 29 | 30 | constructor(config: { 31 | eventBus: IEventBus | null, 32 | repos: Repositories, 33 | loaders: RepositoryLoaders, 34 | }) { 35 | this.#eventBus = config.eventBus; 36 | this.#repos = config.repos; 37 | this.#loaders = config.loaders; 38 | } 39 | 40 | /** 41 | * FIXME 42 | * Should implement global proper Saga orchestrator 43 | */ 44 | async commit( 45 | aggregateMap: AggregateMap, 46 | ): Promise { 47 | const shouldPublish: AnyDomainEvent[] = []; 48 | 49 | try { 50 | for (const value of Object.values(aggregateMap)) { 51 | if (!(value instanceof Aggregate)) { 52 | continue; 53 | } 54 | 55 | const repo = this.#getRepository(value.typename); 56 | const loader = this.#getLoader(value.typename); 57 | 58 | const commited = await repo.commit(value as any); 59 | if (!commited) { 60 | throw new CommandError(value); 61 | } 62 | shouldPublish.push(...commited); 63 | loader.clear(value.id).prime(value.id, value as any); 64 | } 65 | } finally { 66 | if (this.#eventBus) { 67 | this.#eventBus.push( 68 | ...shouldPublish.sort((a, b) => a.eventDate - b.eventDate), 69 | ); 70 | } 71 | this.#cleanupEvents(aggregateMap); 72 | } 73 | 74 | return aggregateMap; 75 | } 76 | 77 | #getRepository(name: string): Repository { 78 | const aggregator = this.#repos[name as RepositoryName]; 79 | if (!aggregator) { 80 | throw new ConfigurationError(`repository for ${name} is not configured`); 81 | } 82 | return aggregator; 83 | } 84 | 85 | #getLoader(name: string): RepositoryLoader { 86 | const loader = this.#loaders[name as RepositoryName]; 87 | if (!loader) { 88 | throw new ConfigurationError(`loader for ${name} is not configured`); 89 | } 90 | return loader; 91 | } 92 | 93 | #cleanupEvents( 94 | aggregateMap: AggregateMap, 95 | ) { 96 | for (const value of Object.values(aggregateMap)) { 97 | if (value instanceof Aggregate) { 98 | value.$pullEvents(); 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /playground-application/src/runtime/QueryResult.ts: -------------------------------------------------------------------------------- 1 | import { Condition } from '@cometjs/core'; 2 | import { InvariantError } from '@karrotmini/playground-core/src/errors'; 3 | import { type ExecutionResult } from './ExecutionResult'; 4 | 5 | export type T> = ( 6 | ExecutionResult 7 | ); 8 | 9 | export function map< 10 | ResponseData extends Record, 11 | RData, 12 | RError = null, 13 | >( 14 | result: T, 15 | map: ( 16 | | ((data: ResponseData) => RData) 17 | | { 18 | data: ((data: ResponseData) => RData), 19 | errors: ((errors: readonly Error[]) => RError), 20 | } 21 | ), 22 | ): RData | RError { 23 | if (typeof map === 'function') { 24 | if (result.data) { 25 | return map(result.data); 26 | } 27 | if (result.errors) { 28 | return null as unknown as RError; 29 | } 30 | } 31 | 32 | if (map && typeof map === 'object') { 33 | if (result.data) { 34 | return map.data(result.data); 35 | } 36 | if (result.errors) { 37 | return map.errors( 38 | result.errors 39 | .map(e => e.originalError) 40 | .filter(Condition.isTruthy), 41 | ); 42 | } 43 | } 44 | 45 | throw new InvariantError('invalid mapExecutionResult params'); 46 | } 47 | 48 | export function unwrap< 49 | ResponseData extends Record, 50 | >( 51 | result: T, 52 | ): ResponseData | null { 53 | return map(result, { 54 | data: result => result, 55 | errors: () => null, 56 | }); 57 | } 58 | 59 | export function unwrapErrors< 60 | ResponseData extends Record, 61 | >( 62 | result: T, 63 | ): readonly Error[] | null { 64 | return map(result, { 65 | data: () => null, 66 | errors: result => result, 67 | }); 68 | } 69 | -------------------------------------------------------------------------------- /playground-application/src/runtime/Reporter.ts: -------------------------------------------------------------------------------- 1 | type LogInfoFn = (message: string, ...args: unknown[]) => void; 2 | type LogErrorFn = (message: string | Error, ...args: unknown[]) => void; 3 | type CaptureExceptionFn = (exception: unknown) => void; 4 | 5 | export interface IReporter { 6 | debug: LogInfoFn; 7 | info: LogInfoFn; 8 | warn: LogInfoFn; 9 | error: LogErrorFn; 10 | captureException: CaptureExceptionFn; 11 | } 12 | -------------------------------------------------------------------------------- /playground-application/src/runtime/Resource.ts: -------------------------------------------------------------------------------- 1 | import { makeGlobalID } from '@cometjs/relay-utils'; 2 | 3 | const goi = makeGlobalID(); 4 | 5 | export type T = { 6 | typename: string, 7 | id: string, 8 | }; 9 | 10 | export function toGlobalId({ typename, id }: T): string { 11 | return goi.toID({ typename: IdDictionary[typename] || typename, id }); 12 | } 13 | 14 | export function fromGlobalId(globalId: string): T { 15 | const { typename, id } = goi.fromID(globalId); 16 | return { typename: IdDictionary[typename] || typename, id }; 17 | } 18 | 19 | const IdDictionary: Record = { 20 | UserProfile: '0', 21 | 0: 'UserProfile', 22 | 23 | App: '1', 24 | 1: 'App', 25 | 26 | BundleUpload: '2', 27 | 2: 'BundleUpload', 28 | 29 | BundleTemplate: '3', 30 | 3: 'BundleTemplate', 31 | 32 | CustomHost: '4', 33 | 4: 'CustomHost', 34 | }; 35 | -------------------------------------------------------------------------------- /playground-application/src/runtime/ResourceAuthorizer.ts: -------------------------------------------------------------------------------- 1 | import * as Resource from './Resource'; 2 | 3 | export class AuthorizationError extends TypeError { 4 | } 5 | 6 | export class UnauthorizedError extends Error { 7 | constructor(resource: Resource.T, level: string) { 8 | super(`${resource.typename}(${resource.id}):${level} 권한이 없습니다.`); 9 | } 10 | } 11 | 12 | /** 13 | * 기본적인 Resource-based access control을 담당합니다. 14 | */ 15 | export interface IResourceAuthorizer { 16 | permit(resource: Resource.T, level: string): void; 17 | permitByGlobalId(globalId: string, level: string): void; 18 | prohibit(resource: Resource.T): void; 19 | prohibitByGlobalId(globalId: string): void; 20 | guard(resource: Resource.T, level: string): void; 21 | } 22 | -------------------------------------------------------------------------------- /playground-application/src/runtime/Tracer.ts: -------------------------------------------------------------------------------- 1 | export interface ITracer { 2 | startSpan(name: string): ISpan; 3 | } 4 | 5 | export interface ISpan { 6 | end(): void; 7 | } 8 | -------------------------------------------------------------------------------- /playground-application/src/runtime/_common.ts: -------------------------------------------------------------------------------- 1 | import type DataLoader from 'dataloader'; 2 | import { 3 | type EntityID, 4 | type Aggregator, 5 | } from '@karrotmini/playground-core/src/framework'; 6 | import { 7 | type IBundleStorage, 8 | type IHostnameProvider, 9 | type IAppRepository, 10 | type IBundleUploadRepository, 11 | type ICustomHostRepository, 12 | type IUserProfileRepository, 13 | } from '../ports'; 14 | 15 | export type Repositories = Readonly<{ 16 | App: IAppRepository, 17 | BundleUpload: IBundleUploadRepository, 18 | CustomHost: ICustomHostRepository, 19 | UserProfile: IUserProfileRepository, 20 | }>; 21 | 22 | export type RepositoryName = keyof Repositories; 23 | export type Repository = Repositories[RepositoryName]; 24 | 25 | export type RepositoryLoaders = Readonly<{ 26 | [K in RepositoryName]: ( 27 | Repositories[K] extends Aggregator 28 | ? DataLoader, Aggregate | null, string> 29 | : never 30 | ) 31 | }>; 32 | export type RepositoryLoader = RepositoryLoaders[RepositoryName]; 33 | 34 | export type Services = Readonly<{ 35 | bundleStorage: IBundleStorage, 36 | hostnameProvider: IHostnameProvider, 37 | }>; 38 | -------------------------------------------------------------------------------- /playground-application/src/runtime/index.ts: -------------------------------------------------------------------------------- 1 | export * from './_common'; 2 | export * from './ApplicationContext'; 3 | export * from './EventBus'; 4 | export * from './Executor'; 5 | export * from './Mutator'; 6 | export * from './Reporter'; 7 | export * from './Tracer'; 8 | 9 | export * as Resource from './Resource'; 10 | export * from './ResourceAuthorizer'; 11 | 12 | export * as QueryResult from './QueryResult'; 13 | export * as MutationResult from './MutationResult'; 14 | -------------------------------------------------------------------------------- /playground-application/src/test/helpers.ts: -------------------------------------------------------------------------------- 1 | import { webcrypto } from 'node:crypto'; 2 | import { 3 | vi, 4 | expect, 5 | type Mock, 6 | } from 'vitest'; 7 | 8 | import { 9 | type AnyDomainEvent, 10 | } from '@karrotmini/playground-core/src/framework'; 11 | import { 12 | MemoryEventBus, 13 | VitestApplicationContext, 14 | } from '../builtin'; 15 | import { 16 | Executor, 17 | } from '../runtime'; 18 | 19 | export type SpyOf = ( 20 | T[K] extends ((...args: infer TArgs) => infer TReturn) 21 | ? Mock 22 | : never 23 | ); 24 | 25 | export function eventMatch( 26 | eventPartial: Partial, 27 | ): E { 28 | return expect.objectContaining(eventPartial); 29 | } 30 | 31 | export function setupApplication() { 32 | const eventBus = new MemoryEventBus(); 33 | 34 | const context = new VitestApplicationContext({ 35 | eventBus, 36 | vitestUtils: vi, 37 | crypto: webcrypto as unknown as Crypto, 38 | }); 39 | 40 | const executor = new Executor({ 41 | context: { application: context }, 42 | }); 43 | 44 | return { eventBus, context, executor }; 45 | } 46 | -------------------------------------------------------------------------------- /playground-application/src/usecases/CreateUserApp.generated.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import type * as Types from '../__generated__/types'; 3 | 4 | import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; 5 | export type CreateUserAppMutationVariables = Types.Exact<{ 6 | userProfileId: Types.Scalars['ID']; 7 | appId: Types.Scalars['String']; 8 | name: Types.Scalars['String']; 9 | }>; 10 | 11 | 12 | export type CreateUserAppMutation = { __typename: 'Mutation', createApp: { __typename: 'CreateAppResult', app: { __typename: 'App', id: string }, customHost: { __typename: 'CustomHost', id: string } } }; 13 | 14 | 15 | export const CreateUserAppDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateUserApp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userProfileId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createApp"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"userProfileId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userProfileId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"customHost"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; -------------------------------------------------------------------------------- /playground-application/src/usecases/CreateUserApp.graphql: -------------------------------------------------------------------------------- 1 | mutation CreateUserApp( 2 | $userProfileId: ID! 3 | $appId: String! 4 | $name: String! 5 | ) { 6 | createApp(input: { 7 | userProfileId: $userProfileId 8 | appId: $appId 9 | name: $name 10 | }) { 11 | app { 12 | id 13 | } 14 | customHost { 15 | id 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /playground-application/src/usecases/CreateUserProfile.generated.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import type * as Types from '../__generated__/types'; 3 | 4 | import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; 5 | export type CreateUserProfileMutationVariables = Types.Exact<{ [key: string]: never; }>; 6 | 7 | 8 | export type CreateUserProfileMutation = { __typename: 'Mutation', createUserProfile: { __typename: 'CreateUserProfileResult', userProfile: { __typename: 'UserProfile', id: string } } }; 9 | 10 | 11 | export const CreateUserProfileDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateUserProfile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createUserProfile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userProfile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; -------------------------------------------------------------------------------- /playground-application/src/usecases/CreateUserProfile.graphql: -------------------------------------------------------------------------------- 1 | mutation CreateUserProfile { 2 | createUserProfile { 3 | userProfile { 4 | id 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /playground-application/src/usecases/CreateUserProfile.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect } from 'vitest'; 2 | 3 | import { 4 | UserProfileID, 5 | } from '@karrotmini/playground-core/src/entities'; 6 | import { 7 | MutationResult, 8 | } from '../runtime'; 9 | import { 10 | setupApplication, 11 | eventMatch, 12 | } from '../test/helpers'; 13 | 14 | import { CreateUserProfileDocument } from './CreateUserProfile.generated'; 15 | 16 | describe('CreateUserProfile', test => { 17 | test('happy path', async () => { 18 | const { 19 | executor, 20 | context, 21 | eventBus, 22 | } = setupApplication(); 23 | 24 | const aggregateId = UserProfileID('TEST'); 25 | context.repos.UserProfile.newId 26 | .mockResolvedValueOnce(aggregateId); 27 | 28 | const result = await executor.execute( 29 | CreateUserProfileDocument, { 30 | }, 31 | ); 32 | 33 | expect(MutationResult.unwrap(result)).not.toBeNull(); 34 | expect(context.repos.UserProfile.commit).toHaveBeenCalledOnce(); 35 | 36 | const record = eventBus.pull(); 37 | expect(record).toEqual( 38 | [ 39 | eventMatch({ 40 | aggregateId, 41 | aggregateName: 'UserProfile', 42 | eventName: 'UserProfileCreated', 43 | eventPayload: { 44 | name: null, 45 | profileImageUrl: null, 46 | }, 47 | }), 48 | ], 49 | ); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /playground-application/src/usecases/CreateUserProfileWithFirstApp.generated.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import type * as Types from '../__generated__/types'; 3 | 4 | import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; 5 | export type CreateUserProfileWithFirstAppMutationVariables = Types.Exact<{ 6 | appId: Types.Scalars['String']; 7 | name: Types.Scalars['String']; 8 | }>; 9 | 10 | 11 | export type CreateUserProfileWithFirstAppMutation = { __typename: 'Mutation', createUserProfile: { __typename: 'CreateUserProfileResult', createApp: { __typename: 'CreateUserProfileResultCreateAppResult', userProfile: { __typename: 'UserProfile', id: string }, customHost: { __typename: 'CustomHost', id: string }, app: { __typename: 'App', id: string } } } }; 12 | 13 | 14 | export const CreateUserProfileWithFirstAppDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateUserProfileWithFirstApp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createUserProfile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createApp"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userProfile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"customHost"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]}}]} as unknown as DocumentNode; -------------------------------------------------------------------------------- /playground-application/src/usecases/CreateUserProfileWithFirstApp.graphql: -------------------------------------------------------------------------------- 1 | mutation CreateUserProfileWithFirstApp( 2 | $appId: String! 3 | $name: String! 4 | ) { 5 | createUserProfile { 6 | createApp(input: { 7 | appId: $appId 8 | name: $name 9 | }) { 10 | userProfile { 11 | id 12 | } 13 | customHost { 14 | id 15 | } 16 | app { 17 | id 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /playground-application/src/usecases/ListUserApps.generated.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import type * as Types from '../__generated__/types'; 3 | 4 | import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; 5 | export type AppCardFragment = { __typename: 'App', id: string, manifest: { __typename: 'AppManifest', name: string, icon: URL | string }, canonicalHost: { __typename: 'CustomHost', providerInfo: { __typename: 'HostnameProviderInfo', url: URL | string } } }; 6 | 7 | export type ListUserAppsQueryVariables = Types.Exact<{ 8 | userProfileId: Types.Scalars['ID']; 9 | }>; 10 | 11 | 12 | export type ListUserAppsQuery = { __typename: 'Query', userProfile: { __typename: 'UserProfile', apps: Array<{ __typename: 'App', id: string, manifest: { __typename: 'AppManifest', name: string, icon: URL | string }, canonicalHost: { __typename: 'CustomHost', providerInfo: { __typename: 'HostnameProviderInfo', url: URL | string } } }> } | null }; 13 | 14 | export const AppCardFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AppCard"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"App"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"manifest"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canonicalHost"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"providerInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]}}]} as unknown as DocumentNode; 15 | export const ListUserAppsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ListUserApps"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userProfileId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userProfile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userProfileId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apps"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AppCard"}}]}}]}}]}},...AppCardFragmentDoc.definitions]} as unknown as DocumentNode; -------------------------------------------------------------------------------- /playground-application/src/usecases/ListUserApps.graphql: -------------------------------------------------------------------------------- 1 | fragment AppCard on App { 2 | id 3 | manifest { 4 | name 5 | icon 6 | } 7 | canonicalHost { 8 | providerInfo { 9 | url 10 | } 11 | } 12 | } 13 | 14 | query ListUserApps( 15 | $userProfileId: ID! 16 | ) { 17 | userProfile(id: $userProfileId) { 18 | apps { 19 | ...AppCard 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /playground-application/src/usecases/UserProfileById.generated.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import type * as Types from '../__generated__/types'; 3 | 4 | import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; 5 | export type UserProfileByIdQueryVariables = Types.Exact<{ 6 | userProfileId: Types.Scalars['ID']; 7 | }>; 8 | 9 | 10 | export type UserProfileByIdQuery = { __typename: 'Query', userProfile: { __typename: 'UserProfile', id: string, name: string, profileImageUrl: URL | string } | null }; 11 | 12 | 13 | export const UserProfileByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserProfileById"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userProfileId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userProfile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userProfileId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"profileImageUrl"}}]}}]}}]} as unknown as DocumentNode; -------------------------------------------------------------------------------- /playground-application/src/usecases/UserProfileById.graphql: -------------------------------------------------------------------------------- 1 | query UserProfileById($userProfileId: ID!) { 2 | userProfile(id: $userProfileId) { 3 | id 4 | name 5 | profileImageUrl 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /playground-application/src/usecases/_mutations.ts: -------------------------------------------------------------------------------- 1 | export * from './CreateUserApp.generated'; 2 | export * from './CreateUserProfile.generated'; 3 | export * from './CreateUserProfileWithFirstApp.generated'; 4 | -------------------------------------------------------------------------------- /playground-application/src/usecases/_queries.ts: -------------------------------------------------------------------------------- 1 | export * from './UserProfileById.generated'; 2 | export * from './ListUserApps.generated'; 3 | -------------------------------------------------------------------------------- /playground-application/src/usecases/index.ts: -------------------------------------------------------------------------------- 1 | export * as Queries from './_queries'; 2 | export * as Mutations from './_mutations'; 3 | -------------------------------------------------------------------------------- /playground-application/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true 5 | }, 6 | "include": [ 7 | "./src/**/*.ts", 8 | "./src/**/*.json" 9 | ], 10 | "exclude": [ 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /playground-application/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | }, 6 | }); 7 | -------------------------------------------------------------------------------- /playground-bundle-storage/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | .mf/ 3 | -------------------------------------------------------------------------------- /playground-bundle-storage/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "playground-bundle-storage" 3 | version = "0.0.1" 4 | authors = ["Hyeseong Kim "] 5 | edition = "2021" 6 | 7 | [lib] 8 | crate-type = ["cdylib", "rlib"] 9 | 10 | [features] 11 | default = ["console_error_panic_hook"] 12 | 13 | [dependencies] 14 | cfg-if = "0.1.2" 15 | worker = "0.0.9" 16 | serde = { version = "1.0", features = ["derive"] } 17 | serde_json = "1.0" 18 | 19 | # The `console_error_panic_hook` crate provides better debugging of panics by 20 | # logging them with `console.error`. This is great for development, but requires 21 | # all the `std::fmt` and `std::panicking` infrastructure, so isn't great for 22 | # code size when deploying. 23 | console_error_panic_hook = { version = "0.1.1", optional = true } 24 | 25 | karrotmini-miniapp-manifest = { path = "../karrotmini-miniapp-manifest" } 26 | karrotmini-miniapp-package = { path = "../karrotmini-miniapp-package" } 27 | -------------------------------------------------------------------------------- /playground-bundle-storage/README.md: -------------------------------------------------------------------------------- 1 | # playground-bundle-storage 2 | -------------------------------------------------------------------------------- /playground-bundle-storage/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@karrotmini/playground-bundle-storage", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "miniflare": "miniflare", 7 | "wrangler": "wrangler" 8 | }, 9 | "devDependencies": { 10 | "miniflare": "^2.6.0", 11 | "wrangler": "^2.0.22" 12 | }, 13 | "packageManager": "yarn@3.2.1" 14 | } 15 | -------------------------------------------------------------------------------- /playground-bundle-storage/src/controllers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod upload_bundle_content; 2 | -------------------------------------------------------------------------------- /playground-bundle-storage/src/controllers/upload_bundle_content.rs: -------------------------------------------------------------------------------- 1 | use karrotmini_miniapp_manifest::v1::manifest::Manifest; 2 | use karrotmini_miniapp_package::package::Package; 3 | use serde::{Serialize, Deserialize}; 4 | use worker::*; 5 | 6 | #[derive(Debug, Serialize, Deserialize)] 7 | pub struct ResponseBody { 8 | manifest: Option, 9 | } 10 | 11 | pub async fn post(mut req: Request, ctx: RouteContext<()>) -> Result { 12 | let storage = ctx.kv("KV_BUNDLE_STORAGE")?; 13 | if let Some(id) = ctx.param("id") { 14 | let form = req.form_data().await?; 15 | if let Some(FormEntry::File(file)) = form.get("content") { 16 | console_log!("{} {}", file.name(), file.type_()); 17 | let key = "bundle:".to_string() + id; 18 | let bytes = file.bytes().await?; 19 | return match Package::from_bytes(bytes.clone()) { 20 | Ok(package) => { 21 | storage.put_bytes(key.as_str(), bytes.as_slice())?.execute().await?; 22 | let body = ResponseBody { 23 | manifest: package.manifest, 24 | }; 25 | Response::from_json(&body) 26 | }, 27 | Err(err) => { 28 | Response::error(err.to_string(), 400) 29 | }, 30 | }; 31 | } 32 | } 33 | Response::error("Bad Request", 400) 34 | } 35 | -------------------------------------------------------------------------------- /playground-bundle-storage/src/lib.rs: -------------------------------------------------------------------------------- 1 | use karrotmini_miniapp_package::package::Package; 2 | use serde_json::json; 3 | use worker::*; 4 | 5 | mod controllers; 6 | mod utils; 7 | 8 | #[event(fetch)] 9 | pub async fn main(req: Request, env: Env, _ctx: worker::Context) -> Result { 10 | utils::set_panic_hook(); 11 | 12 | Router::new() 13 | .get_async("/bundle/:id/manifest", get_bundle_manifest) 14 | .post_async("/bundle/:id/upload", controllers::upload_bundle_content::post) 15 | .post_async("/bundle/:id/connect_hostname", connect_bundle_hostname) 16 | .run(req, env).await 17 | } 18 | 19 | async fn get_bundle_manifest(mut _req: Request, ctx: RouteContext<()>) -> Result { 20 | let storage = ctx.kv("KV_BUNDLE_STORAGE")?; 21 | if let Some(id) = ctx.param("id") { 22 | let key = "bundle:".to_string() + id; 23 | let content = storage.get(key.as_str()).bytes().await?; 24 | return match content { 25 | Some(bytes) => match Package::from_bytes(bytes) { 26 | Ok(Package { manifest: Some(manifest), .. }) => { 27 | Response::from_json(&manifest) 28 | }, 29 | Ok(_) => { 30 | Response::from_json(&json!(null)) 31 | }, 32 | Err(err) => { 33 | Response::error(err.to_string(), 500) 34 | }, 35 | }, 36 | None => Response::error("Bundle not found", 404), 37 | }; 38 | } 39 | Response::error("Bad Request", 400) 40 | } 41 | 42 | async fn connect_bundle_hostname(mut req: Request, ctx: RouteContext<()>) -> Result { 43 | let storage = ctx.kv("KV_BUNDLE_STORAGE")?; 44 | if let Some(id) = ctx.param("id") { 45 | let form = req.form_data().await?; 46 | return match form.get("hostname") { 47 | Some(FormEntry::Field(hostname)) => { 48 | let key = "hostname:".to_string() + hostname.as_str(); 49 | storage.put(key.as_str(), id.as_str())?.execute().await?; 50 | Response::empty() 51 | }, 52 | Some(_) => { 53 | Response::error("\"hostname\" must be a string", 404) 54 | }, 55 | None => { 56 | Response::error("\"hostname\" is required", 404) 57 | }, 58 | }; 59 | } 60 | Response::error("Bad Request", 400) 61 | } 62 | 63 | -------------------------------------------------------------------------------- /playground-bundle-storage/src/utils.rs: -------------------------------------------------------------------------------- 1 | use cfg_if::cfg_if; 2 | 3 | cfg_if! { 4 | // https://github.com/rustwasm/console_error_panic_hook#readme 5 | if #[cfg(feature = "console_error_panic_hook")] { 6 | extern crate console_error_panic_hook; 7 | pub use self::console_error_panic_hook::set_once as set_panic_hook; 8 | } else { 9 | #[inline] 10 | pub fn set_panic_hook() {} 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /playground-bundle-storage/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "karrotmini-playground-bundle-storage" 2 | workers_dev = true 3 | compatibility_date = "2022-07-18" 4 | compatibility_flags = [ 5 | "url_standard" 6 | ] 7 | account_id = "aad5c82543cd1f267b89737d0f56405e" 8 | 9 | [vars] 10 | WORKERS_RS_VERSION = "0.0.9" 11 | 12 | [[kv_namespaces]] 13 | binding = "KV_BUNDLE_STORAGE" 14 | id = "9ef081f0198246058c3c8a4f5d86a68d" 15 | preview_id = "e35c547eef174fa1a224d799b64ffc62" 16 | 17 | [build] 18 | command = "worker-build --release" 19 | 20 | [build.upload] 21 | dir = "build/worker" 22 | format = "modules" 23 | main = "./shim.mjs" 24 | 25 | [[build.upload.rules]] 26 | globs = ["**/*.wasm"] 27 | type = "CompiledWasm" 28 | 29 | [miniflare] 30 | kv_persist = true 31 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/README.md: -------------------------------------------------------------------------------- 1 | # Karrotmini Playground Adapter to Cloudflare 2 | 3 | [Cloudflare KV](https://developers.cloudflare.com/workers/runtime-apis/kv/) & [Durable Objects](https://developers.cloudflare.com/workers/runtime-apis/durable-objects/) 백엔드로 구현된 Playground 어댑터 4 | 5 | - In: Playground 서비스를 Cloudflare 인프라스트럭쳐(KV, Durable Objects)로 포팅합니다. 6 | - Out: [Fetch API](https://developer.mozilla.org/ko/docs/Web/API/Fetch_API) 기반 클라이언트를 제공합니다. [Service Bindings](https://developers.cloudflare.com/workers/platform/bindings/about-service-bindings/) Stub을 감싸서 사용합니다. 7 | 8 | ## Infrastructure 9 | 10 | ![Infrastructure Overview](_images/playground-cloudflare-infrastructure-overview.png) 11 | [See on Excalidraw](https://excalidraw.com/#json=bgse0GaQT4xgha9bR7e9z,YKRMirhUr5y3FTmOy2CF6Q) 12 | 13 | ### Key Components 14 | 15 | - **Event Store**: 글로벌 분산 스토리지인 *Cloudflare KV*에 발행된 이벤트를 모두 저장합니다. 애그리게잇 ID, 이벤트 명, 이벤트 날짜 기반으로 정렬된 키를 각각 보관하여 이론적으로 [Hexastore](http://karras.rutgers.edu/hexastore.pdf) 수준으로 인덱싱이 가능합니다. 16 | > **Warning** 17 | > Cloudflare KV 비용은 DO Transactional Storage 대비 [읽기의 경우 최소 2.5배, 쓰기의 경우 최소 5배 비쌉니다.](https://developers.cloudflare.com/workers/platform/pricing/#workers-kv) 18 | > DO는 작업 단위를 병합할 수도 있으므로 Hexastore 구현 시 쓰기 비용이 (30 * 이벤트 갯수)배 이상 차이날 수 있습니다. 19 | > 애초에 DO에선 Materialize가 쉬워서 별도의 인덱싱이 필요하지 않습니다. 20 | > 그럼에도 불구하고 KV를 사용하는 이유는 관리 편이성의 차이 때문이며 가까운 시일 내에 비용 문제로 마이그레이션 할 수 있습니다. 21 | > 22 | > 예상 비용 매트릭: U x C x E x 6 / month 23 | > (U: 고유 사용자 수, C: 사용자 당 커맨드 갯수 평균, E: 커맨드 당 이벤트 갯수 평균) 24 | - **Aggregators**: 고유한 애그리게잇 ID 마다 스폰되는 *Durable Object*를 기반으로 애그리게이터 인터페이스를 구현합니다. 25 | - 동시에 한 군데서만 실행되는 것으로 애그리게잇 상태의 일관성을 보장합니다. 26 | - 일정시간동안 메모리에 상태가 보존되어 스토리지 요청 없이 빠르게 상태를 반환합니다. 27 | - 기본적으로 이벤트 스토어에서 상태를 복원하며 일정 주기로 [자체 스토리지](https://developers.cloudflare.com/workers/runtime-apis/durable-objects/#transactional-storage-api)에 저장한 스냅샷으로 더 빠르게 복원할 수 있습니다. 28 | - **Event Bus**, **Projectors**: TBD 29 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/_images/playground-cloudflare-infrastructure-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karrotmini/playground/c134037f7ac1adb1a16da9a384ce7eaa824b5246/playground-cloudflare-adapter/_images/playground-cloudflare-infrastructure-overview.png -------------------------------------------------------------------------------- /playground-cloudflare-adapter/lib/.gitignore: -------------------------------------------------------------------------------- 1 | /lib/ 2 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@karrotmini/playground-cloudflare-adapter", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@karromtini/playground-cloudflare-adapter-transport": "workspace:^", 7 | "@karrotmini/playground-application": "workspace:^", 8 | "@karrotmini/playground-core": "workspace:^" 9 | }, 10 | "devDependencies": { 11 | "typescript": "^4.7.4" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/lib/src/AppRepository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | AppID, 4 | type AppEvent, 5 | } from '@karrotmini/playground-core/src'; 6 | import { 7 | type IAppRepository, 8 | } from '@karrotmini/playground-application/src'; 9 | 10 | import { 11 | PlaygroundCloudflareAdapterClient, 12 | type ServiceBinding, 13 | } from '@karromtini/playground-cloudflare-adapter-transport/src/client'; 14 | 15 | export class AppRepository 16 | implements IAppRepository 17 | { 18 | #client: PlaygroundCloudflareAdapterClient; 19 | 20 | constructor(config: { 21 | service: ServiceBinding, 22 | }) { 23 | this.#client = new PlaygroundCloudflareAdapterClient({ 24 | service: config.service, 25 | }); 26 | } 27 | 28 | async newId(): Promise { 29 | const response = await this.#client.request({ 30 | action: 'App_newID', 31 | payload: {}, 32 | }); 33 | return AppID(response.id); 34 | } 35 | 36 | async aggregate(id: AppID): Promise { 37 | const response = await this.#client.request({ 38 | action: 'App_aggregate', 39 | payload: { 40 | id, 41 | }, 42 | }); 43 | return response && new App(id, response.snapshot); 44 | } 45 | 46 | async commit(app: App): Promise { 47 | const response = await this.#client.request({ 48 | action: 'App_commit', 49 | payload: { 50 | id: app.id, 51 | snapshot: app.$snapshot, 52 | events: app.$pullEvents(), 53 | }, 54 | }); 55 | return response.published; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/lib/src/BundleUploadRepository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BundleUpload, 3 | BundleUploadID, 4 | type BundleUploadEvent, 5 | } from '@karrotmini/playground-core/src'; 6 | import { 7 | type IBundleUploadRepository, 8 | } from '@karrotmini/playground-application/src'; 9 | 10 | import { 11 | PlaygroundCloudflareAdapterClient, 12 | type ServiceBinding, 13 | } from '@karromtini/playground-cloudflare-adapter-transport/src/client'; 14 | 15 | export class BundleUploadRepository 16 | implements IBundleUploadRepository 17 | { 18 | #client: PlaygroundCloudflareAdapterClient; 19 | 20 | constructor(config: { 21 | service: ServiceBinding, 22 | }) { 23 | this.#client = new PlaygroundCloudflareAdapterClient({ 24 | service: config.service, 25 | }); 26 | } 27 | 28 | async newId(): Promise { 29 | const response = await this.#client.request({ 30 | action: 'BundleUpload_newID', 31 | payload: {}, 32 | }); 33 | return BundleUploadID(response.id); 34 | } 35 | 36 | async aggregate(id: BundleUploadID): Promise { 37 | const response = await this.#client.request({ 38 | action: 'BundleUpload_aggregate', 39 | payload: { 40 | id, 41 | }, 42 | }); 43 | return response && new BundleUpload(id, response.snapshot); 44 | } 45 | 46 | async commit(app: BundleUpload): Promise { 47 | const response = await this.#client.request({ 48 | action: 'BundleUpload_commit', 49 | payload: { 50 | id: app.id, 51 | snapshot: app.$snapshot, 52 | events: app.$pullEvents(), 53 | }, 54 | }); 55 | return response.published; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/lib/src/CustomHostRepository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomHost, 3 | CustomHostID, 4 | type CustomHostEvent, 5 | } from '@karrotmini/playground-core/src'; 6 | import { 7 | type ICustomHostRepository, 8 | } from '@karrotmini/playground-application/src'; 9 | 10 | import { 11 | PlaygroundCloudflareAdapterClient, 12 | type ServiceBinding, 13 | } from '@karromtini/playground-cloudflare-adapter-transport/src/client'; 14 | 15 | export class CustomHostRepository 16 | implements ICustomHostRepository 17 | { 18 | #client: PlaygroundCloudflareAdapterClient; 19 | 20 | constructor(config: { 21 | service: ServiceBinding, 22 | }) { 23 | this.#client = new PlaygroundCloudflareAdapterClient({ 24 | service: config.service, 25 | }); 26 | } 27 | 28 | async newId(): Promise { 29 | const response = await this.#client.request({ 30 | action: 'CustomHost_newID', 31 | payload: {}, 32 | }); 33 | return CustomHostID(response.id); 34 | } 35 | 36 | async aggregate(id: CustomHostID): Promise { 37 | const response = await this.#client.request({ 38 | action: 'CustomHost_aggregate', 39 | payload: { 40 | id, 41 | }, 42 | }); 43 | return response && new CustomHost(id, response.snapshot); 44 | } 45 | 46 | async commit(app: CustomHost): Promise { 47 | const response = await this.#client.request({ 48 | action: 'CustomHost_commit', 49 | payload: { 50 | id: app.id, 51 | snapshot: app.$snapshot, 52 | events: app.$pullEvents(), 53 | }, 54 | }); 55 | return response && response.published; 56 | } 57 | 58 | async queryByHostname(hostname: string): Promise { 59 | throw new Error('not implemented'); 60 | } 61 | 62 | async writeIndex(customHost: CustomHost): Promise { 63 | throw new Error('not implemented'); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/lib/src/UserProfileRepository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UserProfile, 3 | UserProfileID, 4 | type UserProfileEvent, 5 | } from '@karrotmini/playground-core/src'; 6 | import { 7 | type IUserProfileRepository, 8 | } from '@karrotmini/playground-application/src'; 9 | 10 | import { 11 | PlaygroundCloudflareAdapterClient, 12 | type ServiceBinding, 13 | } from '@karromtini/playground-cloudflare-adapter-transport/src/client'; 14 | 15 | export class UserProfileRepository 16 | implements IUserProfileRepository 17 | { 18 | #client: PlaygroundCloudflareAdapterClient; 19 | 20 | constructor(config: { 21 | service: ServiceBinding, 22 | }) { 23 | this.#client = new PlaygroundCloudflareAdapterClient({ 24 | service: config.service, 25 | }); 26 | } 27 | 28 | async newId(): Promise { 29 | const response = await this.#client.request({ 30 | action: 'UserProfile_newID', 31 | payload: {}, 32 | }); 33 | return UserProfileID(response.id); 34 | } 35 | 36 | async aggregate(id: UserProfileID): Promise { 37 | const response = await this.#client.request({ 38 | action: 'UserProfile_aggregate', 39 | payload: { 40 | id, 41 | }, 42 | }); 43 | return response && new UserProfile(id, response.snapshot); 44 | } 45 | 46 | async commit(userProfile: UserProfile): Promise { 47 | const response = await this.#client.request({ 48 | action: 'UserProfile_commit', 49 | payload: { 50 | id: userProfile.id, 51 | snapshot: userProfile.$snapshot, 52 | events: userProfile.$pullEvents(), 53 | }, 54 | }); 55 | return response.published; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/lib/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AppRepository'; 2 | export * from './BundleUploadRepository'; 3 | export * from './CustomHostRepository'; 4 | export * from './UserProfileRepository'; 5 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "moduleResolution": "NodeNext", 5 | "rootDir": "src", 6 | "outDir": "lib" 7 | }, 8 | "include": [ 9 | "src" 10 | ], 11 | "exclude": [ 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/transport/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@karromtini/playground-cloudflare-adapter-transport", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@karrotmini/playground-core": "workspace:^" 7 | }, 8 | "devDependencies": { 9 | "@cloudflare/workers-types": "^3.13.0", 10 | "typescript": "^4.7.4" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/transport/src/client.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ServiceBinding, 3 | RequestMessage, 4 | ResponseMessage, 5 | } from './types'; 6 | 7 | export * from './types'; 8 | 9 | export class PlaygroundCloudflareAdapterServiceError extends Error { 10 | } 11 | 12 | export class PlaygroundCloudflareAdapterTransportError extends Error { 13 | } 14 | 15 | export class PlaygroundCloudflareAdapterClient { 16 | #service: ServiceBinding; 17 | 18 | constructor(config: { 19 | service: ServiceBinding, 20 | }) { 21 | this.#service = config.service; 22 | } 23 | 24 | async request( 25 | message: T, 26 | ): Promise['result']> { 30 | const url = new URL('http://playground-cloudflare-adapter'); 31 | const request = new Request(url, { 32 | body: JSON.stringify(message), 33 | }); 34 | 35 | let response: ResponseMessage; 36 | try { 37 | const res = await this.#service.fetch(request); 38 | response = await res.json() as ResponseMessage; 39 | } catch (e: any) { 40 | throw new PlaygroundCloudflareAdapterTransportError( 41 | e.message || e.toString(), 42 | ); 43 | } 44 | 45 | if (!response.success) { 46 | throw new PlaygroundCloudflareAdapterServiceError(response.message); 47 | } 48 | 49 | return response.result as any; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/transport/src/handler.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import type { 4 | Action, 5 | ActionMap, 6 | RequestMessage, 7 | ResponseMessage, 8 | } from './types'; 9 | 10 | export * from './types'; 11 | 12 | export type ServiceStub = { 13 | [K in Action]: ( 14 | message: ActionMap[K]['request'], 15 | env: Env, 16 | ctx: ExecutionContext, 17 | ) => Promise 18 | }; 19 | 20 | export function makePlaygroundServiceHandler(stub: ServiceStub): ExportedHandlerFetchHandler { 21 | return async function (request, env, ctx) { 22 | const message = await request.json() as RequestMessage; 23 | const action = message.action; 24 | 25 | let responseBody: string; 26 | try { 27 | const result = await stub[action]( 28 | message.payload as any, 29 | env, 30 | ctx, 31 | ); 32 | const response = { 33 | success: true, 34 | action, 35 | result, 36 | } as ResponseMessage; 37 | responseBody = JSON.stringify(response); 38 | } catch (e: any) { 39 | const response = { 40 | success: false, 41 | action, 42 | message: e.message || e.toString(), 43 | } as ResponseMessage; 44 | responseBody = JSON.stringify(response); 45 | } 46 | 47 | return new Response(responseBody, { 48 | headers: { 49 | 'Content-Type': 'application/json', 50 | }, 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/transport/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": [ 4 | "src" 5 | ], 6 | "exclude": [ 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/worker/.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | .mf/ 3 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@karrotmini/playground-cloudflare-adapter-worker", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "wrangler": "wrangler" 7 | }, 8 | "dependencies": { 9 | "@karromtini/playground-cloudflare-adapter-transport": "workspace:^", 10 | "@karrotmini/playground-application": "workspace:^", 11 | "@karrotmini/playground-core": "workspace:^" 12 | }, 13 | "devDependencies": { 14 | "@cloudflare/workers-types": "^3.13.0", 15 | "esbuild": "^0.14.47", 16 | "typescript": "^4.7.4", 17 | "wrangler": "^2.0.15" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/worker/src/base/HexaKV.ts: -------------------------------------------------------------------------------- 1 | interface Serializable { 2 | toString(): string; 3 | } 4 | 5 | export type SPO< 6 | S extends Serializable = Serializable, 7 | P extends Serializable = Serializable, 8 | O extends Serializable = Serializable, 9 | > = { s: S, p: P, o: O }; 10 | 11 | type S = T extends SPO ? S : never; 12 | type P = T extends SPO ? P : never; 13 | type O = T extends SPO ? O : never; 14 | 15 | export class HexaKV { 16 | static spliter = '::'; 17 | static join = (...keys: string[]) => keys.join(HexaKV.spliter); 18 | 19 | static SPO = ({ s, p, o }: K) => HexaKV.join('spo', s.toString(), p.toString(), o.toString()); 20 | static SOP = ({ s, p, o }: K) => HexaKV.join('sop', s.toString(), o.toString(), p.toString()); 21 | static PSO = ({ s, p, o }: K) => HexaKV.join('pso', p.toString(), s.toString(), o.toString()); 22 | static POS = ({ s, p, o }: K) => HexaKV.join('pos', p.toString(), o.toString(), s.toString()); 23 | static OSP = ({ s, p, o }: K) => HexaKV.join('osp', o.toString(), s.toString(), p.toString()); 24 | static OPS = ({ s, p, o }: K) => HexaKV.join('ops', o.toString(), p.toString(), s.toString()); 25 | 26 | #namespace: KVNamespace; 27 | 28 | constructor(config: { 29 | namespace: KVNamespace; 30 | }) { 31 | this.#namespace = config.namespace; 32 | } 33 | 34 | async put(key: K, value: V) { 35 | const v = JSON.stringify(value); 36 | await Promise.all([ 37 | this.#namespace.put(HexaKV.SPO(key), v), 38 | this.#namespace.put(HexaKV.SOP(key), v), 39 | this.#namespace.put(HexaKV.PSO(key), v), 40 | this.#namespace.put(HexaKV.POS(key), v), 41 | this.#namespace.put(HexaKV.OSP(key), v), 42 | this.#namespace.put(HexaKV.OPS(key), v), 43 | ]); 44 | } 45 | 46 | async listPO(props: { 47 | predicate: { s: S }, 48 | cursor?: string | null, 49 | limit?: number, 50 | }): Promise<{ 51 | values: Array, 52 | cursor?: string, 53 | listCompleted: boolean, 54 | }> { 55 | const s = props.predicate.s.toString(); 56 | const listResult = await this.#namespace.list({ 57 | prefix: HexaKV.join('spo', s), 58 | cursor: props.cursor, 59 | limit: props.limit, 60 | }); 61 | const values = await Promise.all( 62 | listResult.keys.map( 63 | key => this.#namespace.get(key.name, 'json'), 64 | ), 65 | ) as V[]; 66 | return { 67 | values, 68 | cursor: listResult.cursor, 69 | listCompleted: listResult.list_complete, 70 | }; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/worker/src/base/Util.ts: -------------------------------------------------------------------------------- 1 | export function generateShortId(n = 13) { 2 | const CHARS = '0123456789abcdefghijklmnopqrstuvwxyz'; 3 | const N = CHARS.length; 4 | 5 | let id = ''; 6 | for (let i = 0; i < n; i++) { 7 | id += CHARS.charAt(Math.random() * N | 0); 8 | } 9 | return id; 10 | } 11 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/worker/src/dos/AppDurableObject.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | type AppID, 4 | type AppSnapshot, 5 | } from '@karrotmini/playground-core/src'; 6 | 7 | import { 8 | AggregatorDurableObject, 9 | } from '../base/DurableObjectAggregatorProtocol'; 10 | 11 | export class AppDurableObject 12 | extends AggregatorDurableObject 13 | implements DurableObject 14 | { 15 | readonly aggregateName = 'App' as const; 16 | 17 | spawn(id: AppID, snapshot?: AppSnapshot): App { 18 | return new App(id, snapshot); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/worker/src/dos/BundleUploadDurableObject.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BundleUpload, 3 | type BundleUploadID, 4 | type BundleUploadSnapshot, 5 | } from '@karrotmini/playground-core/src'; 6 | 7 | import { 8 | AggregatorDurableObject, 9 | } from '../base/DurableObjectAggregatorProtocol'; 10 | 11 | export class BundleUploadDurableObject 12 | extends AggregatorDurableObject 13 | implements DurableObject 14 | { 15 | readonly aggregateName = 'BundleUpload' as const; 16 | 17 | spawn(id: BundleUploadID, snapshot?: BundleUploadSnapshot): BundleUpload { 18 | return new BundleUpload(id, snapshot); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/worker/src/dos/CustomHostDurableObject.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomHost, 3 | type CustomHostID, 4 | type CustomHostSnapshot, 5 | } from '@karrotmini/playground-core/src'; 6 | 7 | import { 8 | AggregatorDurableObject, 9 | } from '../base/DurableObjectAggregatorProtocol'; 10 | 11 | export class CustomHostDurableObject 12 | extends AggregatorDurableObject 13 | implements DurableObject 14 | { 15 | readonly aggregateName = 'CustomHost' as const; 16 | 17 | spawn(id: CustomHostID, snapshot?: CustomHostSnapshot): CustomHost { 18 | return new CustomHost(id, snapshot); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/worker/src/dos/UserProfileDurableObject.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UserProfile, 3 | UserProfileID, 4 | type UserProfileSnapshot, 5 | } from '@karrotmini/playground-core/src'; 6 | 7 | import { 8 | AggregatorDurableObject, 9 | } from '../base/DurableObjectAggregatorProtocol'; 10 | 11 | export class UserProfileDurableObject 12 | extends AggregatorDurableObject 13 | implements DurableObject 14 | { 15 | readonly aggregateName = 'UserProfile' as const; 16 | 17 | spawn(id: UserProfileID, snapshot?: UserProfileSnapshot) { 18 | return new UserProfile(id, snapshot); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/worker/src/dos/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AppDurableObject'; 2 | export * from './BundleUploadDurableObject'; 3 | export * from './CustomHostDurableObject'; 4 | export * from './UserProfileDurableObject'; 5 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/worker/src/repos/AppRepository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | AppID, 4 | type AppSnapshot, 5 | Utils, 6 | } from '@karrotmini/playground-core/src'; 7 | import { 8 | type IAppRepository, 9 | } from '@karrotmini/playground-application/src'; 10 | 11 | import { 12 | AggregatorProtocolClient, 13 | } from '../base/DurableObjectAggregatorProtocol'; 14 | 15 | export class AppRepository 16 | extends AggregatorProtocolClient 17 | implements IAppRepository 18 | { 19 | #namespace: DurableObjectNamespace; 20 | #seq: Generator; 21 | 22 | constructor(config: { 23 | namespace: DurableObjectNamespace, 24 | }) { 25 | super(config); 26 | this.#namespace = config.namespace; 27 | this.#seq = Utils.shortId(Math.random, 13); 28 | } 29 | 30 | newId(): Promise { 31 | const id = this.#seq.next().value; 32 | return Promise.resolve(AppID(id)); 33 | } 34 | 35 | convertId(id: AppID): DurableObjectId { 36 | return this.#namespace.idFromName(id); 37 | } 38 | 39 | spawn(id: AppID, snapshot?: AppSnapshot): App { 40 | return new App(id, snapshot); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/worker/src/repos/BundleUploadRepository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BundleUpload, 3 | BundleUploadID, 4 | type BundleUploadSnapshot, 5 | } from '@karrotmini/playground-core/src'; 6 | import { 7 | type IBundleUploadRepository, 8 | } from '@karrotmini/playground-application/src'; 9 | 10 | import { 11 | AggregatorProtocolClient, 12 | } from '../base/DurableObjectAggregatorProtocol'; 13 | 14 | export class BundleUploadRepository 15 | extends AggregatorProtocolClient 16 | implements IBundleUploadRepository 17 | { 18 | #namespace: DurableObjectNamespace; 19 | 20 | constructor(config: { 21 | namespace: DurableObjectNamespace, 22 | }) { 23 | super(config); 24 | this.#namespace = config.namespace; 25 | } 26 | 27 | newId(): Promise { 28 | const id = this.#namespace.newUniqueId(); 29 | return Promise.resolve(BundleUploadID(id.toString())); 30 | } 31 | 32 | convertId(id: BundleUploadID): DurableObjectId { 33 | return this.#namespace.idFromString(id); 34 | } 35 | 36 | spawn(id: BundleUploadID, snapshot?: BundleUploadSnapshot): BundleUpload { 37 | return new BundleUpload(id, snapshot); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/worker/src/repos/CustomHostRepository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomHost, 3 | CustomHostID, 4 | type CustomHostSnapshot, 5 | } from '@karrotmini/playground-core/src'; 6 | import { 7 | type ICustomHostRepository, 8 | } from '@karrotmini/playground-application/src'; 9 | 10 | import { 11 | AggregatorProtocolClient, 12 | } from '../base/DurableObjectAggregatorProtocol'; 13 | import * as Util from '../base/Util'; 14 | 15 | export class CustomHostRepository 16 | extends AggregatorProtocolClient 17 | implements ICustomHostRepository 18 | { 19 | #namespace: DurableObjectNamespace; 20 | 21 | constructor(config: { 22 | namespace: DurableObjectNamespace, 23 | }) { 24 | super(config); 25 | this.#namespace = config.namespace; 26 | } 27 | 28 | newId(): Promise { 29 | const id = this.#namespace.newUniqueId(); 30 | return Promise.resolve(CustomHostID(id.toString())); 31 | } 32 | 33 | convertId(id: CustomHostID): DurableObjectId { 34 | return this.#namespace.idFromString(id); 35 | } 36 | 37 | spawn(id: CustomHostID, snapshot?: CustomHostSnapshot): CustomHost { 38 | return new CustomHost(id, snapshot); 39 | } 40 | 41 | writeIndex(customHost: CustomHost): Promise { 42 | throw new Error('not implemented'); 43 | } 44 | 45 | queryByHostname(hostname: string): Promise { 46 | throw new Error('not implemented'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/worker/src/repos/UserProfileRepository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UserProfile, 3 | UserProfileID, 4 | type UserProfileSnapshot, 5 | Utils, 6 | } from '@karrotmini/playground-core/src'; 7 | import { 8 | type IUserProfileRepository, 9 | } from '@karrotmini/playground-application/src'; 10 | 11 | import { 12 | AggregatorProtocolClient, 13 | } from '../base/DurableObjectAggregatorProtocol'; 14 | 15 | export class UserProfileRepository 16 | extends AggregatorProtocolClient 17 | implements IUserProfileRepository 18 | { 19 | #namespace: DurableObjectNamespace; 20 | #seq: Generator; 21 | 22 | constructor(config: { 23 | namespace: DurableObjectNamespace, 24 | }) { 25 | super(config); 26 | this.#namespace = config.namespace; 27 | this.#seq = Utils.shortId(Math.random, 13); 28 | } 29 | 30 | newId(): Promise { 31 | const id = this.#seq.next().value; 32 | return Promise.resolve(UserProfileID(id)); 33 | } 34 | 35 | convertId(id: UserProfileID): DurableObjectId { 36 | return this.#namespace.idFromName(id); 37 | } 38 | 39 | spawn(id: UserProfileID, snapshot?: UserProfileSnapshot): UserProfile { 40 | return new UserProfile(id, snapshot); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/worker/src/repos/index.ts: -------------------------------------------------------------------------------- 1 | export * from './BundleUploadRepository'; 2 | export * from './AppRepository'; 3 | export * from './CustomHostRepository'; 4 | export * from './UserProfileRepository'; 5 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"] 5 | }, 6 | "include": [ 7 | "./wrangler.d.ts", 8 | "./src" 9 | ], 10 | "exclude": [ 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/worker/wrangler.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare interface WranglerEnv { 4 | // KVs 5 | KV_EVENT_STORE: KVNamespace; 6 | 7 | // DOs 8 | DO_App: DurableObjectNamespace; 9 | DO_BundleUpload: DurableObjectNamespace; 10 | DO_CustomHost: DurableObjectNamespace; 11 | DO_UserProfile: DurableObjectNamespace; 12 | } 13 | -------------------------------------------------------------------------------- /playground-cloudflare-adapter/worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "karrotmini-playground-adapter" 2 | workers_dev = true 3 | compatibility_date = "2022-05-22" 4 | compatibility_flags = [ 5 | "url_standard", 6 | ] 7 | account_id = "aad5c82543cd1f267b89737d0f56405e" 8 | 9 | [build] 10 | watch_dir = "src" 11 | command = "yarn esbuild src/worker.ts --bundle --minify --outfile=dist/worker.mjs --format=esm" 12 | 13 | # Deprecated in Wrangler v2, But still required for miniflare 14 | [build.upload] 15 | format = "modules" 16 | dir = "./dist" 17 | main = "./worker.mjs" 18 | 19 | [[kv_namespaces]] 20 | binding = "KV_EVENT_STORE" 21 | id = "853c2294e23848e5bc8128abca316c0e" 22 | preview_id = "36e7d6e06600400e8c030e9c9a376980" 23 | 24 | [[durable_objects.bindings]] 25 | name = "DO_App" 26 | class_name = "AppDurableObject" 27 | 28 | [[durable_objects.bindings]] 29 | name = "DO_BundleUpload" 30 | class_name = "BundleUploadDurableObject" 31 | 32 | [[durable_objects.bindings]] 33 | name = "DO_CustomHost" 34 | class_name = "CustomHostDurableObject" 35 | 36 | [[durable_objects.bindings]] 37 | name = "DO_UserProfile" 38 | class_name = "UserProfileDurableObject" 39 | 40 | [[migrations]] 41 | tag = "v1" 42 | new_classes = [ 43 | "AppDurableObject", 44 | "BundleUploadDurableObject", 45 | "CustomHostDurableObject", 46 | "UserProfileDurableObject", 47 | ] 48 | 49 | [miniflare] 50 | kv_persist = true 51 | durable_objects_persist = true 52 | -------------------------------------------------------------------------------- /playground-core/.gitattributes: -------------------------------------------------------------------------------- 1 | /dependencygraph.svg linguist-generated 2 | -------------------------------------------------------------------------------- /playground-core/README.md: -------------------------------------------------------------------------------- 1 | # Karrotmini Playground Core Logic 2 | 3 | Note: Node.js 환경에서 실행하는 경우 v17.0.0+ 이상 버전이 필요합니다. 4 | -------------------------------------------------------------------------------- /playground-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@karrotmini/playground-core", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "test:run": "vitest run --coverage", 7 | "test:dev": "vitest" 8 | }, 9 | "devDependencies": { 10 | "c8": "^7.11.3", 11 | "typescript": "^4.7.4", 12 | "vite": "^2.9.9", 13 | "vitest": "^0.17.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /playground-core/src/entities/.gitattributes: -------------------------------------------------------------------------------- 1 | _snapshots.ts linguist-generated 2 | -------------------------------------------------------------------------------- /playground-core/src/entities/App.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect } from 'vitest'; 2 | import { 3 | App, 4 | AppID, 5 | BundleTemplate, 6 | BundleTemplateID, 7 | CustomHostID, 8 | UserProfileID, 9 | type DeploymentRef, 10 | } from '../entities'; 11 | 12 | describe('App', test => { 13 | test('should have valid state after AppCreated', () => { 14 | const id = AppID('TEST'); 15 | const templateId = BundleTemplateID('TEST'); 16 | const ownerId = UserProfileID('TEST'); 17 | const customHostId = CustomHostID('TEST'); 18 | 19 | const app = new App(id); 20 | app.$publishEvent({ 21 | aggregateId: id, 22 | aggregateName: 'App', 23 | eventName: 'AppCreatedFromTemplate', 24 | eventDate: new Date('2022-06-10').getTime(), 25 | eventPayload: { 26 | name: 'test', 27 | tenantId: 'test.karrotmini.app', 28 | templateId, 29 | ownerId, 30 | customHostId, 31 | }, 32 | }); 33 | 34 | expect(app.$valid).toBe(true); 35 | expect(app.$snapshot).toMatchSnapshot(`$snapshot version ${app.snapshotVersion}`); 36 | }); 37 | 38 | test('bootstrapFromTemplate', () => { 39 | const id = AppID('TEST'); 40 | const ownerId = UserProfileID('TEST'); 41 | const customHostId = CustomHostID('TEST'); 42 | const templateId = BundleTemplate.centeringDiv().id; 43 | 44 | const { app, deployment } = App.bootstrapFromTemplate({ 45 | name: 'test', 46 | tenantId: 'test.karrotmini.app', 47 | id, 48 | ownerId, 49 | customHostId, 50 | templateId, 51 | }); 52 | 53 | const expectedDeployment: DeploymentRef = { 54 | name: 'live', 55 | bundle: { 56 | kind: 'Template', 57 | value: { 58 | id: templateId, 59 | }, 60 | }, 61 | custom_host_id: customHostId, 62 | deployed_at: expect.any(Number), 63 | }; 64 | 65 | expect(deployment).toEqual(expectedDeployment); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /playground-core/src/entities/AppIcon.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | import { describe, expect } from 'vitest'; 4 | 5 | import { AppIcon } from '../entities'; 6 | 7 | describe('AppIcon', test => { 8 | test('fallbackSVG should be valid data URI', async () => { 9 | const fallbackSVG = AppIcon.createFallbackSVG({ 10 | size: 100, 11 | text: 'test', 12 | color: ['#000000', '#ffffff'], 13 | }); 14 | expect(() => new URL(fallbackSVG)).not.toThrow(); 15 | expect(fallbackSVG.protocol).toEqual('data:'); 16 | expect(fallbackSVG.pathname).toMatch('image/svg+xml,'); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /playground-core/src/entities/AppIcon.ts: -------------------------------------------------------------------------------- 1 | import { encodeSVG } from '../utils'; 2 | 3 | export class AppIcon extends URL { 4 | get isRemote(): boolean { 5 | return this.protocol.startsWith('http'); 6 | } 7 | 8 | static createFallbackSVG(props: { 9 | size: number, 10 | text: string, 11 | color: [string, string], 12 | }): AppIcon { 13 | const svg = AppIcon.fallbackSVGString(props); 14 | const encoded = encodeSVG(svg); 15 | const dataUri = `data:image/svg+xml,${encoded}`; 16 | return new AppIcon(dataUri); 17 | } 18 | 19 | static fallbackSVGString(props: { 20 | size: number, 21 | text: string, 22 | color: [string, string], 23 | }): string { 24 | const { size, color: [color1, color2] } = props; 25 | const fontSize = size * 0.6 | 0; 26 | const text = props.text[0].toUpperCase(); 27 | const svg = ` 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ${text} 38 | 39 | 40 | `.trim(); 41 | return svg; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /playground-core/src/entities/AppManifest.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AppNameRequiredError, 3 | ReservedAppIdError, 4 | } from '../errors'; 5 | 6 | export type AppManifestPayload = { 7 | app_id: string, 8 | name: string, 9 | }; 10 | 11 | export class AppManifest { 12 | #appId: string; 13 | #name: string; 14 | 15 | get name() { 16 | return this.#name; 17 | } 18 | 19 | get appId() { 20 | return this.#appId; 21 | } 22 | 23 | constructor(payload: AppManifestPayload) { 24 | AppManifest.validatePayload(payload); 25 | this.#appId = payload.app_id; 26 | this.#name = payload.name; 27 | } 28 | 29 | toJSON(): AppManifestPayload { 30 | return { 31 | app_id: this.appId, 32 | name: this.name, 33 | }; 34 | } 35 | 36 | static validatePayload(payload: AppManifestPayload) { 37 | AppManifest.validateAppId(payload.app_id); 38 | AppManifest.validateAppName(payload.name); 39 | } 40 | 41 | static validateAppName(appName: string) { 42 | if (!appName) { 43 | throw new AppNameRequiredError(); 44 | } 45 | } 46 | 47 | static validateAppId(appId: string): void { 48 | // 더 막을 거 있나...? 있으면 추가 49 | const blocklist = [ 50 | 'playground', 51 | 'cli', 52 | 'docs', 53 | 'test', 54 | 'daangn', 55 | 'karrot', 56 | 'karrotpay', 57 | 'karrotmini', 58 | 'karrotmarket', 59 | ]; 60 | if (blocklist.includes(appId)) { 61 | throw new ReservedAppIdError(blocklist); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /playground-core/src/entities/Bundle.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type BundleTemplate, 3 | type BundleTemplateID, 4 | type BundleUpload, 5 | type BundleUploadID, 6 | } from '../entities'; 7 | 8 | export type { BundleRef } from './_snapshots'; 9 | 10 | export type Bundle = BundleTemplate | BundleUpload; 11 | export type BundleID = Bundle['id']; 12 | -------------------------------------------------------------------------------- /playground-core/src/entities/BundleTemplate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | registerGUID, 4 | type GUID, 5 | } from '../framework'; 6 | 7 | export type BundleTemplateID = GUID<'BundleTemplate'>; 8 | export const BundleTemplateID = registerGUID(); 9 | 10 | export class BundleTemplate 11 | extends Entity 12 | { 13 | readonly typename = 'BundleTemplate' as const; 14 | 15 | static centeringDiv() { 16 | const templateId = BundleTemplateID('__CENTERING_DIV'); 17 | return new BundleTemplate(templateId); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /playground-core/src/entities/BundleUpload.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect } from 'vitest'; 2 | 3 | import { 4 | AppID, 5 | BundleUpload, 6 | BundleUploadID, 7 | UserProfileID, 8 | } from '../entities'; 9 | 10 | describe('BundleUpload', test => { 11 | test('should have valid state after BundleUpload', () => { 12 | const id = BundleUploadID('TEST'); 13 | const appId = AppID('TEST'); 14 | const uploaderId = UserProfileID('TEST'); 15 | 16 | const upload = new BundleUpload(id); 17 | upload.$publishEvent({ 18 | aggregateId: id, 19 | aggregateName: 'BundleUpload', 20 | eventName: 'BundleUploaded', 21 | eventDate: new Date('2022-06-10').getTime(), 22 | eventPayload: { 23 | tag: '', 24 | appId, 25 | uploaderId, 26 | }, 27 | }); 28 | 29 | expect(upload.$valid).toBe(true); 30 | expect(upload.$snapshot).toMatchSnapshot(`$snapshot version ${upload.snapshotVersion}`); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /playground-core/src/entities/BundleUpload.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Aggregate, 3 | registerGUID, 4 | registerSnapshot, 5 | type GUID, 6 | type Snapshot, 7 | } from '../framework'; 8 | import { 9 | type BundleUploadedEvent, 10 | } from '../events'; 11 | import { 12 | type AppID, 13 | type UserProfileID, 14 | } from '../entities'; 15 | import { 16 | writeBundleUploadSnapshotV1, 17 | type BundleUploadSnapshotV1, 18 | } from './_snapshots'; 19 | 20 | export type BundleUploadID = GUID<'BundleUpload'>; 21 | export const BundleUploadID = registerGUID(); 22 | 23 | export type BundleUploadSnapshot = Snapshot<1, { 24 | tag: string, 25 | appId: AppID, 26 | uploaderId: UserProfileID, 27 | createdAt: number, 28 | }>; 29 | export const BundleUploadSnapshot = registerSnapshot(); 30 | 31 | export type BundleUploadEvent = ( 32 | | BundleUploadedEvent 33 | ); 34 | 35 | export type BundleUploadDTO = { 36 | id: BundleUploadID, 37 | }; 38 | 39 | export class BundleUpload 40 | extends Aggregate 41 | { 42 | readonly typename = 'BundleUpload' as const; 43 | readonly snapshotVersion = 1 as const; 44 | 45 | get tag() { 46 | return this.$snapshot.tag; 47 | } 48 | 49 | get appId() { 50 | return this.$snapshot.appId; 51 | } 52 | 53 | get uploaderId() { 54 | return this.$snapshot.uploaderId; 55 | } 56 | 57 | toJSON(): Readonly { 58 | return Object.freeze({ 59 | id: this.id, 60 | }); 61 | } 62 | 63 | validate(state: Partial): state is BundleUploadSnapshot { 64 | return ( 65 | typeof state.tag === 'string' && 66 | typeof state.appId === 'string' && 67 | typeof state.uploaderId === 'string' && 68 | typeof state.createdAt === 'number' 69 | ); 70 | } 71 | 72 | reduce(current: BundleUploadSnapshot, event: BundleUploadEvent): void { 73 | switch (event.eventName) { 74 | case 'BundleUploaded': { 75 | current.appId = event.eventPayload.appId; 76 | current.uploaderId = event.eventPayload.uploaderId; 77 | current.createdAt = event.eventDate; 78 | current.tag = event.eventPayload.tag; 79 | break; 80 | } 81 | } 82 | } 83 | 84 | static create(props: { 85 | id: BundleUploadID, 86 | uploaderId: UserProfileID, 87 | appId: AppID, 88 | tag: string, 89 | }) { 90 | const id = props.id; 91 | const upload = new BundleUpload(id); 92 | upload.$publishEvent({ 93 | aggregateName: upload.typename, 94 | aggregateId: id, 95 | eventName: 'BundleUploaded', 96 | eventDate: Date.now(), 97 | eventPayload: { 98 | tag: props.tag, 99 | appId: props.appId, 100 | uploaderId: props.uploaderId, 101 | }, 102 | }); 103 | return upload; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /playground-core/src/entities/CustomHost.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect } from 'vitest'; 2 | 3 | import { 4 | CustomHost, 5 | CustomHostID, 6 | HostnameProviderInfo, 7 | } from '../entities'; 8 | 9 | describe('CustomHost', test => { 10 | test('should be valid after CustomHostProvisioned', () => { 11 | const id = CustomHostID('TEST'); 12 | const providerInfo = new HostnameProviderInfo({ 13 | hostname: 'test.karrotmini.app', 14 | healthCheckUrl: 'https://management', 15 | managementUrl: 'https://healthcheck', 16 | }); 17 | 18 | const customHost = new CustomHost(id); 19 | customHost.$publishEvent({ 20 | aggregateId: id, 21 | aggregateName: 'CustomHost', 22 | eventName: 'CustomHostProvisioned', 23 | eventDate: new Date('2022-06-10').getTime(), 24 | eventPayload: { 25 | providerInfo: providerInfo.toJSON(), 26 | }, 27 | }); 28 | 29 | expect(customHost.$valid).toBe(true); 30 | expect(customHost.$snapshot).toMatchSnapshot( 31 | `$snapshot version ${customHost.snapshotVersion}`, 32 | ); 33 | }); 34 | 35 | test('create without appId', () => { 36 | const id = CustomHostID('TEST'); 37 | const providerInfo = new HostnameProviderInfo({ 38 | hostname: 'test.karrotmini.app', 39 | healthCheckUrl: 'https://management', 40 | managementUrl: 'https://healthcheck', 41 | }); 42 | 43 | const customHost = CustomHost.createWithProviderInfo({ 44 | id, 45 | providerInfo, 46 | }); 47 | 48 | expect(customHost.toJSON()).toEqual({ 49 | id, 50 | providerInfo: providerInfo.toJSON(), 51 | connectedApp: null, 52 | }); 53 | }); 54 | 55 | test('create with appId', () => { 56 | const id = CustomHostID('TEST'); 57 | const providerInfo = new HostnameProviderInfo({ 58 | hostname: 'test.karrotmini.app', 59 | healthCheckUrl: 'https://management', 60 | managementUrl: 'https://healthcheck', 61 | }); 62 | 63 | const customHost = CustomHost.createWithProviderInfo({ 64 | id, 65 | providerInfo, 66 | }); 67 | 68 | expect(customHost.toJSON()).toEqual({ 69 | id, 70 | providerInfo: providerInfo.toJSON(), 71 | connectedApp: null, 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /playground-core/src/entities/Deployment.ts: -------------------------------------------------------------------------------- 1 | export type { DeploymentRef } from './_snapshots'; 2 | -------------------------------------------------------------------------------- /playground-core/src/entities/HostnameProviderInfo.ts: -------------------------------------------------------------------------------- 1 | export type HostnameProviderInfoPayload = { 2 | hostname: string, 3 | healthCheckUrl: string, 4 | managementUrl: string, 5 | }; 6 | 7 | export class HostnameProviderInfo { 8 | #hostname: string; 9 | #healthCheckUrl: URL; 10 | #managementUrl: URL; 11 | 12 | get hostname() { 13 | return this.#hostname; 14 | } 15 | 16 | get healthCheckUrl() { 17 | return this.#healthCheckUrl; 18 | } 19 | 20 | get managementUrl() { 21 | return this.#managementUrl; 22 | } 23 | 24 | constructor(payload: HostnameProviderInfoPayload) { 25 | this.#hostname = payload.hostname; 26 | this.#healthCheckUrl = new URL(payload.healthCheckUrl); 27 | this.#managementUrl = new URL(payload.managementUrl); 28 | } 29 | 30 | toJSON(): Readonly { 31 | return Object.freeze({ 32 | hostname: this.hostname, 33 | healthCheckUrl: this.healthCheckUrl.toJSON(), 34 | managementUrl: this.managementUrl.toJSON(), 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /playground-core/src/entities/UserProfile.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect } from 'vitest'; 2 | 3 | import { 4 | UserProfile, 5 | UserProfileID, 6 | } from '../entities'; 7 | 8 | describe('UserProfile', test => { 9 | test('should be valid after UserProfileCreated', () => { 10 | const id = UserProfileID('TEST'); 11 | const userProfile = new UserProfile(id); 12 | userProfile.$publishEvent({ 13 | aggregateId: id, 14 | aggregateName: 'UserProfile', 15 | eventName: 'UserProfileCreated', 16 | eventDate: new Date('2022-06-10').getTime(), 17 | eventPayload: { 18 | name: null, 19 | profileImageUrl: null, 20 | }, 21 | }); 22 | 23 | expect(userProfile.$valid).toBe(true); 24 | expect(userProfile.$snapshot).toMatchSnapshot( 25 | `$snapshot version ${userProfile.snapshotVersion}`, 26 | ); 27 | }); 28 | 29 | test('updateProfile() only affects if there is any change', () => { 30 | const id = UserProfileID('TEST'); 31 | const name = 'TEST'; 32 | const profileImage = new URL('file:///profileImage'); 33 | const userProfile = UserProfile.create({ 34 | id, 35 | name, 36 | profileImage, 37 | }); 38 | userProfile.$pullEvents(); 39 | 40 | userProfile.updateProfile({ 41 | name, 42 | profileImage, 43 | }); 44 | expect(userProfile.$pullEvents()).toHaveLength(0); 45 | 46 | userProfile.updateProfile({ 47 | name: 'CHANGED', 48 | profileImage, 49 | }); 50 | expect(userProfile.$pullEvents()).toHaveLength(1); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /playground-core/src/entities/__snapshots__/App.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1 2 | 3 | exports[`App > should have valid state after AppCreated > $snapshot version 1 1`] = ` 4 | { 5 | "bundles": [ 6 | { 7 | "kind": "Template", 8 | "value": { 9 | "id": "TEST", 10 | }, 11 | }, 12 | ], 13 | "created_at": 1654819200000, 14 | "custom_host_id": "TEST", 15 | "deployments": [], 16 | "name": "test", 17 | "owner_id": "TEST", 18 | "tenant_id": "test.karrotmini.app", 19 | } 20 | `; 21 | -------------------------------------------------------------------------------- /playground-core/src/entities/__snapshots__/AppBundleUpload.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1 2 | 3 | exports[`AppBundleUpload > should have valid state after AppBundleUpload > $snapshot version 1 1`] = ` 4 | { 5 | "appId": "TEST", 6 | "createdAt": 1654819200000, 7 | "deletedAt": null, 8 | "uploaderId": "TEST", 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /playground-core/src/entities/__snapshots__/BundleUpload.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1 2 | 3 | exports[`BundleUpload > should have valid state after BundleUpload > $snapshot version 1 1`] = ` 4 | { 5 | "appId": "TEST", 6 | "createdAt": 1654819200000, 7 | "tag": "", 8 | "uploaderId": "TEST", 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /playground-core/src/entities/__snapshots__/CustomHost.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1 2 | 3 | exports[`CustomHost > should be valid after CustomHostProvisioned > $snapshot version 1 1`] = ` 4 | { 5 | "connectedApp": null, 6 | "createdAt": 1654819200000, 7 | "providerInfo": { 8 | "healthCheckUrl": "https://management/", 9 | "hostname": "test.karrotmini.app", 10 | "managementUrl": "https://healthcheck/", 11 | }, 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /playground-core/src/entities/__snapshots__/UserProfile.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1 2 | 3 | exports[`UserProfile > should be valid after UserProfileCreated > $snapshot version 1 1`] = ` 4 | { 5 | "appIds": [], 6 | "createdAt": 1654819200000, 7 | "deletedAt": null, 8 | "name": null, 9 | "profileImageUrl": null, 10 | } 11 | `; 12 | -------------------------------------------------------------------------------- /playground-core/src/entities/_snapshots.atd: -------------------------------------------------------------------------------- 1 | type timestamp = int 2 | 3 | type app_manifest = { 4 | app_id: string; 5 | name: string; 6 | } 7 | 8 | type app_snapshot_v1 = { 9 | name: string; 10 | created_at: timestamp; 11 | tenant_id: string; 12 | owner_id: string nullable; 13 | custom_host_id: string; 14 | bundles: bundle_ref list; 15 | deployments: (string * deployment_ref) list; 16 | } 17 | 18 | type bundle_template_ref = { 19 | id: string; 20 | } 21 | 22 | type bundle_upload_ref = { 23 | id: string; 24 | } 25 | 26 | type bundle_ref = [ 27 | | Template of bundle_template_ref 28 | | Upload of bundle_upload_ref 29 | ] 30 | 31 | type deployment_ref = { 32 | name: string; 33 | bundle: bundle_ref; 34 | custom_host_id: string; 35 | deployed_at: timestamp; 36 | } 37 | 38 | type bundle_upload_snapshot_v1 = { 39 | app_id: string; 40 | tag: string; 41 | uploader_id: string; 42 | created_at: timestamp; 43 | manifest: app_manifest nullable; 44 | } 45 | 46 | type user_profile_snapshot_v1 = { 47 | app_ids: string list; 48 | name: string nullable; 49 | profile_image_url: string nullable; 50 | } 51 | -------------------------------------------------------------------------------- /playground-core/src/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './App'; 2 | export * from './AppIcon'; 3 | export * from './AppManifest'; 4 | export * from './Bundle'; 5 | export * from './BundleTemplate'; 6 | export * from './BundleUpload'; 7 | export * from './CustomHost'; 8 | export * from './Deployment'; 9 | export * from './HostnameProviderInfo'; 10 | export * from './UserProfile'; 11 | -------------------------------------------------------------------------------- /playground-core/src/errors/AppNameRequiredError.ts: -------------------------------------------------------------------------------- 1 | export class AppNameRequiredError extends Error { 2 | constructor() { 3 | super('앱 이름을 입력해주세요.'); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /playground-core/src/errors/CommandError.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AnyAggregate, 3 | } from '../framework'; 4 | 5 | export class CommandError extends TypeError { 6 | constructor(aggregate: AnyAggregate) { 7 | super(`failed to commit ${aggregate.typename}(${aggregate.id})`); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /playground-core/src/errors/ConfigurationError.ts: -------------------------------------------------------------------------------- 1 | export class ConfigurationError extends TypeError { 2 | constructor(hint: string) { 3 | // Note: 환경설정이나 의존성 주입이 제대로 안된 경우 4 | super(`invalid config: ${hint}`); 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /playground-core/src/errors/HostnameAlreadyUsedError.ts: -------------------------------------------------------------------------------- 1 | export class HostnameAlreadyUsedError extends Error { 2 | constructor(hostname: string) { 3 | super(`호스트명이 이미 사용 중입니다. (hostname: ${hostname})`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /playground-core/src/errors/HostnameNotAvailableError.ts: -------------------------------------------------------------------------------- 1 | export class HostnameNotAvailableError extends Error { 2 | constructor(hostname: string) { 3 | super(`호스트명을 사용할 수 없습니다. (hostname: ${hostname})`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /playground-core/src/errors/InvariantError.ts: -------------------------------------------------------------------------------- 1 | export class InvariantError extends TypeError { 2 | constructor(hint: string) { 3 | // Note: 데이터 정합성이 깨지거나, 로직을 치명적으로 잘못 작성한 경우 4 | super(`invariant: ${hint}`); 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /playground-core/src/errors/ProtectedDeploymentError.ts: -------------------------------------------------------------------------------- 1 | export class ProtectedDeploymentError extends TypeError { 2 | constructor(deploymentName: string) { 3 | super(`${deploymentName} 배포는 삭제할 수 없습니다.`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /playground-core/src/errors/ReservedAppIdError.ts: -------------------------------------------------------------------------------- 1 | export class ReservedAppIdError extends Error { 2 | constructor(keywords: string[]) { 3 | super(`앱 ID로 사용할 수 없는 단어에요. (keywords: ${keywords.join(', ')})`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /playground-core/src/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CommandError'; 2 | export * from './ConfigurationError'; 3 | export * from './InvariantError'; 4 | export * from './HostnameAlreadyUsedError'; 5 | export * from './HostnameNotAvailableError'; 6 | export * from './AppNameRequiredError'; 7 | export * from './ReservedAppIdError'; 8 | export * from './ProtectedDeploymentError'; 9 | -------------------------------------------------------------------------------- /playground-core/src/events/AppCreatedFromTemplateEvent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type DomainEvent, 3 | } from '../framework'; 4 | import { 5 | type UserProfileID, 6 | type CustomHostID, 7 | type BundleTemplateID, 8 | } from '../entities'; 9 | 10 | export type AppCreatedFromTemplateEvent = DomainEvent<'App', 'AppCreatedFromTemplate', { 11 | name: string, 12 | tenantId: string, 13 | templateId: BundleTemplateID, 14 | ownerId: UserProfileID | null, 15 | customHostId: CustomHostID, 16 | }>; 17 | -------------------------------------------------------------------------------- /playground-core/src/events/AppDeploymentCreatedEvent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type DomainEvent, 3 | } from '../framework'; 4 | import { 5 | type DeploymentRef, 6 | } from '../entities'; 7 | 8 | export type AppDeploymentCreatedEvent = DomainEvent<'App', 'AppDeploymentCreated', { 9 | deployment: DeploymentRef, 10 | }>; 11 | -------------------------------------------------------------------------------- /playground-core/src/events/AppDeploymentDeletedEvent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type DomainEvent, 3 | } from '../framework'; 4 | 5 | export type AppDeploymentDeletedEvent = DomainEvent<'App', 'AppDeploymentDeleted', { 6 | deploymentName: string, 7 | }>; 8 | -------------------------------------------------------------------------------- /playground-core/src/events/BundleUploadedEvent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type DomainEvent, 3 | } from '../framework'; 4 | import { 5 | type AppID, 6 | type UserProfileID, 7 | } from '../entities'; 8 | 9 | export type BundleUploadedEvent = DomainEvent<'BundleUpload', 'BundleUploaded', { 10 | tag: string, 11 | appId: AppID, 12 | uploaderId: UserProfileID, 13 | }>; 14 | -------------------------------------------------------------------------------- /playground-core/src/events/CustomHostConnectedEvent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type DomainEvent, 3 | } from '../framework'; 4 | import { 5 | type AppID, 6 | } from '../entities'; 7 | 8 | export type CustomHostConnectedEvent = DomainEvent<'CustomHost', 'CustomHostConnected', { 9 | appId: AppID, 10 | deploymentName: string, 11 | }>; 12 | -------------------------------------------------------------------------------- /playground-core/src/events/CustomHostDisconnectedEvent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type DomainEvent, 3 | } from '../framework'; 4 | import { 5 | type AppID, 6 | } from '../entities'; 7 | 8 | export type CustomHostDisconnectedEvent = DomainEvent<'CustomHost', 'CustomHostDisconnected', { 9 | appId: AppID, 10 | }>; 11 | -------------------------------------------------------------------------------- /playground-core/src/events/CustomHostProvisionedEvent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type DomainEvent, 3 | } from '../framework'; 4 | import { 5 | type HostnameProviderInfoPayload, 6 | } from '../entities'; 7 | 8 | export type CustomHostProvisionedEvent = DomainEvent<'CustomHost', 'CustomHostProvisioned', { 9 | providerInfo: HostnameProviderInfoPayload, 10 | }>; 11 | -------------------------------------------------------------------------------- /playground-core/src/events/UserAppAddedEvent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type DomainEvent, 3 | } from '../framework'; 4 | import { 5 | type AppID, 6 | } from '../entities'; 7 | 8 | export type UserAppAddedEvent = DomainEvent<'UserProfile', 'UserAppAdded', { 9 | appId: AppID, 10 | }>; 11 | -------------------------------------------------------------------------------- /playground-core/src/events/UserProfileCreatedEvent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type DomainEvent, 3 | } from '../framework'; 4 | 5 | export type UserProfileCreatedEvent = DomainEvent<'UserProfile', 'UserProfileCreated', { 6 | name: string | null, 7 | profileImageUrl: string | null, 8 | }>; 9 | -------------------------------------------------------------------------------- /playground-core/src/events/UserProfileUpdatedEvent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type DomainEvent, 3 | } from '../framework'; 4 | 5 | export type UserProfileUpdatedEvent = DomainEvent<'UserProfile', 'UserProfileUpdated', { 6 | name: string | null, 7 | profileImageUrl: string | null, 8 | }>; 9 | -------------------------------------------------------------------------------- /playground-core/src/events/index.ts: -------------------------------------------------------------------------------- 1 | export * from './UserProfileCreatedEvent'; 2 | export * from './UserProfileUpdatedEvent'; 3 | export * from './UserAppAddedEvent'; 4 | export * from './AppCreatedFromTemplateEvent'; 5 | export * from './AppDeploymentCreatedEvent'; 6 | export * from './AppDeploymentDeletedEvent'; 7 | export * from './BundleUploadedEvent'; 8 | export * from './CustomHostProvisionedEvent'; 9 | export * from './CustomHostConnectedEvent'; 10 | export * from './CustomHostDisconnectedEvent'; 11 | -------------------------------------------------------------------------------- /playground-core/src/framework/Aggregate.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from './Entity'; 2 | import { type AnyGUID } from './GUID'; 3 | import { type AnyDomainEvent } from './Event'; 4 | import { type AnySnapshot } from './Snapshot'; 5 | import { type Serializable } from './Serializable'; 6 | 7 | export type AnyAggregate = Aggregate; 8 | 9 | export type AggregateEvent = ( 10 | T extends Aggregate 11 | ? Event 12 | : never 13 | ); 14 | 15 | export type AggregateSnapshot = ( 16 | T extends Aggregate 17 | ? Snapshot 18 | : never 19 | ); 20 | 21 | export type AggregateDTO = ( 22 | T extends Aggregate 23 | ? DTO 24 | : never 25 | ); 26 | 27 | /** 28 | * 스냅샷 밸리데이션 실패하는 경우, 사용 전에 초기화를 안했거나, reduce 코드를 잘못 작성했거나 29 | * 30 | * 상태 복원 방법: 코드 수정 후 시스템 재시작 (필요한 경우 스냅샷 버전 업데이트) 31 | */ 32 | export class InvalidStateError extends TypeError { 33 | constructor(aggregate: AnyAggregate) { 34 | super( 35 | `invalid state at ${aggregate.typename}(${aggregate.id}), 36 | requires version ${aggregate.snapshotVersion}`, 37 | ); 38 | } 39 | } 40 | 41 | export abstract class Aggregate< 42 | ID extends AnyGUID, 43 | Event extends AnyDomainEvent, 44 | Snapshot extends AnySnapshot, 45 | DTO extends Serializable, 46 | > extends Entity { 47 | #events: Event[]; 48 | 49 | #valid = false; 50 | #state: Partial; 51 | 52 | get $valid(): boolean { 53 | return this.#valid ||= this.validate(this.#state); 54 | } 55 | 56 | get $snapshot(): Snapshot { 57 | if (this.$valid) { 58 | return this.#state as Snapshot; 59 | } 60 | throw new InvalidStateError(this); 61 | } 62 | 63 | constructor(id: ID, snapshot?: Snapshot, events: Event[] = []) { 64 | super(id); 65 | this.#events = events; 66 | this.#state = {}; 67 | 68 | if (snapshot) { 69 | this.#state = structuredClone(snapshot); 70 | this.#valid = true; 71 | } 72 | } 73 | 74 | readonly abstract typename: ID['__typename']; 75 | readonly abstract snapshotVersion: Snapshot['__version']; 76 | 77 | abstract validate(state: Partial): state is Snapshot; 78 | 79 | abstract reduce(current: Partial, event: Event): void; 80 | 81 | abstract toJSON(): Readonly; 82 | 83 | $publishEvent(event: Event): void { 84 | this.#events.push(event); 85 | this.reduce(this.#state, event); 86 | } 87 | 88 | $restoreState(events: Event[]): void { 89 | for (const event of events) { 90 | this.reduce(this.#state, event); 91 | } 92 | } 93 | 94 | $pullEvents(): Event[] { 95 | const events = this.#events.slice(); 96 | this.#events = []; 97 | return events; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /playground-core/src/framework/Aggregator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type EntityID, 3 | type AnyAggregate, 4 | type AggregateEvent, 5 | } from '../framework'; 6 | 7 | export interface Aggregator { 8 | newId(): Promise>; 9 | aggregate(aggregateId: EntityID): Promise; 10 | commit(aggregate: T): Promise> | null>; 11 | } 12 | -------------------------------------------------------------------------------- /playground-core/src/framework/Entity.ts: -------------------------------------------------------------------------------- 1 | import { type AnyGUID } from './GUID'; 2 | 3 | export type EntityID = T extends Entity ? ID : never; 4 | 5 | export abstract class Entity { 6 | #id: ID; 7 | 8 | get id() { 9 | return this.#id; 10 | } 11 | 12 | constructor(id: ID) { 13 | this.#id = id; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /playground-core/src/framework/Event.ts: -------------------------------------------------------------------------------- 1 | import { type GUID } from './GUID'; 2 | import { type SerializableObject } from './Serializable'; 3 | 4 | export interface DomainEvent< 5 | AggregateName extends string, 6 | EventName extends string, 7 | EventPayload extends SerializableObject, 8 | > { 9 | aggregateName: AggregateName; 10 | aggregateId: GUID; 11 | eventName: EventName; 12 | eventDate: number; 13 | eventPayload: EventPayload; 14 | } 15 | 16 | export type AnyDomainEvent = ( 17 | | DomainEvent 18 | ); 19 | -------------------------------------------------------------------------------- /playground-core/src/framework/GUID.ts: -------------------------------------------------------------------------------- 1 | export type GUID = ( 2 | & string 3 | & { __typename: TypeName } 4 | ); 5 | 6 | export type AnyGUID = GUID; 7 | 8 | type Identity = (value: string) => ID; 9 | function identity(value: string): B { 10 | return value as B; 11 | } 12 | export function registerGUID(): Identity { 13 | return identity; 14 | } 15 | -------------------------------------------------------------------------------- /playground-core/src/framework/Serializable.ts: -------------------------------------------------------------------------------- 1 | export type Serializable = ( 2 | | null 3 | | number 4 | | string 5 | | boolean 6 | | Serializable[] 7 | | SerializableObject 8 | ); 9 | 10 | export type SerializableObject = { 11 | [x: string]: Serializable, 12 | }; 13 | 14 | export type MustSerializable = T; 15 | -------------------------------------------------------------------------------- /playground-core/src/framework/Snapshot.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type SerializableObject, 3 | type MustSerializable, 4 | } from './Serializable'; 5 | 6 | export type Snapshot = ( 7 | & MustSerializable 8 | & { __tag: 'Snapshot', __version: Version } 9 | ); 10 | 11 | export type AnySnapshot = Snapshot; 12 | 13 | type Identity = ( 14 | payload: SnapshotPayload, 15 | ) => S; 16 | function identity( 17 | payload: SnapshotPayload, 18 | ): S { 19 | return payload as unknown as S; 20 | } 21 | export function registerSnapshot(): Identity { 22 | return identity; 23 | } 24 | 25 | export type SnapshotVersion = ( 26 | T extends Snapshot 27 | ? V 28 | : never 29 | ); 30 | 31 | export type SnapshotPayload = ( 32 | T extends Snapshot 33 | ? Omit 34 | : never 35 | ); 36 | -------------------------------------------------------------------------------- /playground-core/src/framework/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Aggregate'; 2 | export * from './Aggregator'; 3 | export * from './Entity'; 4 | export * from './Event'; 5 | export * from './Snapshot'; 6 | export * from './Serializable'; 7 | export * from './GUID'; 8 | export * from './utils'; 9 | -------------------------------------------------------------------------------- /playground-core/src/framework/utils.ts: -------------------------------------------------------------------------------- 1 | export type EmptyObject = Record; 2 | -------------------------------------------------------------------------------- /playground-core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './entities'; 2 | export * from './errors'; 3 | 4 | export * as Utils from './utils'; 5 | 6 | export { 7 | EntityID, 8 | AnyAggregate, 9 | AnyDomainEvent, 10 | AggregateDTO, 11 | AggregateEvent, 12 | AggregateSnapshot, 13 | } from './framework'; 14 | -------------------------------------------------------------------------------- /playground-core/src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect } from 'vitest'; 2 | 3 | import { createRandomColor } from './utils'; 4 | 5 | describe('createRandomColor', test => { 6 | test('should generate hex color', () => { 7 | const expectedPattern = /^#[0-9A-F]{6}$/i; 8 | expect(createRandomColor(() => 0.01)).toMatch(expectedPattern); 9 | expect(createRandomColor(() => 0.50)).toMatch(expectedPattern); 10 | expect(createRandomColor(() => 0.99)).toMatch(expectedPattern); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /playground-core/src/utils.ts: -------------------------------------------------------------------------------- 1 | // generate random number in 0..1 2 | type RNG = () => number; 3 | 4 | export function *shortId(RNG: RNG, len = 13): Generator { 5 | const CHARS = '0123456789abcdefghijklmnopqrstuvwxyz'; 6 | const N = CHARS.length; 7 | 8 | while (true) { 9 | let id = ''; 10 | for (let i = 0; i < len; i++) { 11 | id += CHARS.charAt(RNG() * N | 0); 12 | } 13 | yield id; 14 | } 15 | } 16 | 17 | export function createRandomColor(RNG: RNG): string { 18 | const letters = '0123456789abcdef'; 19 | let color = '#'; 20 | for (let i = 0; i < 6; i++) { 21 | color += letters[RNG() * 16 | 0]; 22 | } 23 | return color; 24 | } 25 | 26 | export function encodeSVG(data: string): string { 27 | data = data.replace(/"/g, '\''); 28 | data = data.replace(/>\s{1,}<'); 29 | data = data.replace(/\s{2,}/g, ' '); 30 | return data.replace(/[\r\n%#()<>?[\\\]^`{|}]/g, encodeURIComponent); 31 | } 32 | -------------------------------------------------------------------------------- /playground-core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": [ 4 | "./src" 5 | ], 6 | "exclude": [ 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /playground-core/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | }, 6 | }); 7 | -------------------------------------------------------------------------------- /playground-management-api/README.md: -------------------------------------------------------------------------------- 1 | # Playground Management API 2 | 3 | Playground 서비스를 관리할 수 있는 API 입니다. 4 | 5 | - In: Cloudflare Workers 기반으로 Playground Management API 서비스를 제공합니다. 6 | - Out: Fetch API 기반 클라이언트 SDK를 제공합니다. Fetch 클라이언트가 있는 Node.js, Deno, Cloudflare Workers 같은 다양한 환경에서 사용할 수 있습니다. 7 | 8 | ## 클라이언트 사용하기 9 | 10 | ```bash 11 | yarn add @karrotmini/playground-management-api@alpha 12 | ``` 13 | 14 | ```ts 15 | import { PlaygroundManagementAPI } from '@karrotmini/playground-management-api'; 16 | 17 | new PlaygroundManagementAPI({ 18 | baseUrl: new URL('https://playground-management-api.internal.karrotmini.dev'), 19 | fetch, 20 | }); 21 | ``` 22 | -------------------------------------------------------------------------------- /playground-management-api/lib/.gitignore: -------------------------------------------------------------------------------- 1 | /lib/ 2 | -------------------------------------------------------------------------------- /playground-management-api/lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@karrotmini/playground-management-api", 3 | "version": "0.0.0-alpha.1", 4 | "exports": { 5 | ".": { 6 | "types": "./lib/PlaygroundManagementAPI.d.ts", 7 | "import": "./lib/PlaygroundManagementAPI.mjs", 8 | "require": "./lib/PlaygroundManagementAPI.cjs" 9 | }, 10 | "./interface": { 11 | "types": "./lib/interface.d.ts", 12 | "import": "./lib/interface.mjs", 13 | "require": "./lib/interface.cjs" 14 | }, 15 | "./package.json": "./package.json" 16 | }, 17 | "publishConfig": { 18 | "access": "public" 19 | }, 20 | "scripts": { 21 | "prepack": "yarn build", 22 | "build": "tsc -p tsconfig.json" 23 | }, 24 | "files": [ 25 | "src", 26 | "lib" 27 | ], 28 | "devDependencies": { 29 | "typescript": "^4.7.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /playground-management-api/lib/src/PlaygroundManagementAPI.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type IPlaygroundManagementAPI, 3 | } from '@karrotmini/playground-management-api/interface'; 4 | 5 | // TODO 6 | export class PlaygroundManagementAPI implements IPlaygroundManagementAPI { 7 | #baseUrl: URL; 8 | #fetch: typeof fetch; 9 | 10 | constructor(config: { 11 | baseUrl: URL, 12 | fetch: typeof fetch; 13 | }) { 14 | this.#baseUrl = config.baseUrl; 15 | this.#fetch = config.fetch; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /playground-management-api/lib/src/interface/CreateApp.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ResourceId, 3 | type AppManifest, 4 | } from './common'; 5 | 6 | export interface IPlaygroundManagementAPI { 7 | /** 8 | * 앱 리소스를 생성합니다. 9 | */ 10 | createApp( 11 | input: CreateAppInput, 12 | ): Promise; 13 | } 14 | 15 | export type CreateAppInput = { 16 | /** 17 | * 초기 오너십 프로필의 ID 18 | * null 인 경우 오너십 없는 앱이 만들어지며 프로필을 통해 접근할 수 없음 19 | */ 20 | userProfileId: ResourceId | null, 21 | 22 | /* 23 | * 앱 초기 manifest 정보 24 | * - name: string (필수) 25 | * - app_id: string (필수) 26 | */ 27 | manifest: AppManifest, 28 | 29 | /** 30 | * minictl 관리 키 31 | */ 32 | managementKey: string, 33 | }; 34 | 35 | export type CreateAppResult = { 36 | appId: ResourceId, 37 | }; 38 | -------------------------------------------------------------------------------- /playground-management-api/lib/src/interface/CreateAppDeployment.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karrotmini/playground/c134037f7ac1adb1a16da9a384ce7eaa824b5246/playground-management-api/lib/src/interface/CreateAppDeployment.ts -------------------------------------------------------------------------------- /playground-management-api/lib/src/interface/CreateUserProfile.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ResourceId, 3 | } from './common'; 4 | 5 | export interface IPlaygroundManagementAPI { 6 | /** 7 | * 프로필 리소스를 생성합니다. 8 | */ 9 | createUserProfile( 10 | input: CreateUserProfileInput, 11 | ): Promise; 12 | } 13 | 14 | export type CreateUserProfileInput = { 15 | /** 16 | * display name 17 | */ 18 | name: string, 19 | 20 | /** 21 | * minictl 관리 키 22 | */ 23 | managementKey: string, 24 | }; 25 | 26 | export type CreateUserProfileResult = { 27 | userProfileId: ResourceId, 28 | }; 29 | -------------------------------------------------------------------------------- /playground-management-api/lib/src/interface/IssueAppCredential.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ResourceId, 3 | type ResourceCredential, 4 | } from './common'; 5 | 6 | export interface IPlaygroundManagementAPI { 7 | /** 8 | * 앱 관리용 크레덴셜을 발급합니다. 9 | */ 10 | issueAppCredential( 11 | input: IssueAppCredentialInput 12 | ): Promise>; 13 | } 14 | 15 | export type IssueAppCredentialInput = { 16 | /** 17 | * 해당 리소스 ID 18 | * 리소스가 없으면 에러 throw 19 | */ 20 | appId: ResourceId, 21 | 22 | /** 23 | * 인가할 리소스 권한 24 | */ 25 | permission: Array, 26 | 27 | /** 28 | * minictl로 발급한 관리 키 29 | */ 30 | managementKey: string, 31 | }; 32 | 33 | export type IssueAppCredentialResult = { 34 | credential: ResourceCredential<'App', Permission>, 35 | }; 36 | -------------------------------------------------------------------------------- /playground-management-api/lib/src/interface/IssueUserProfileCredential.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ResourceId, 3 | type ResourceCredential, 4 | } from './common'; 5 | 6 | export interface IPlaygroundManagementAPI { 7 | /** 8 | * 프로필 관리용 크레덴셜을 발급합니다. 9 | */ 10 | issueUserProfileCredential( 11 | input: IssueUserProfileCredentialInput, 12 | ): Promise>; 13 | } 14 | 15 | export type IssueUserProfileCredentialInput = { 16 | /** 17 | * 해당 리소스 ID 18 | * 리소스가 없으면 에러 throw 19 | */ 20 | userProfileId: ResourceId, 21 | 22 | /** 23 | * 인가할 리소스 권한 24 | */ 25 | permission: Array, 26 | 27 | /** 28 | * minictl로 발급한 관리 키 29 | */ 30 | managementKey: string, 31 | }; 32 | 33 | export type IssueUserProfileCredentialResult = { 34 | credential: ResourceCredential<'UserProfile', Permission>, 35 | }; 36 | 37 | -------------------------------------------------------------------------------- /playground-management-api/lib/src/interface/TransferAppOwnership.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ResourceId, 3 | type ResourceCredential, 4 | } from './common'; 5 | -------------------------------------------------------------------------------- /playground-management-api/lib/src/interface/UpdateAppManifest.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ResourceId, 3 | type AppManifest, 4 | } from './common'; 5 | 6 | export interface IPlaygroundManagementAPI { 7 | /** 8 | * 앱의 대표 매니페스트 정보를 변경합니다. 9 | * live 번들의 10 | */ 11 | updateAppManifest( 12 | input: UpdateAppManifestInput, 13 | ): Promise; 14 | } 15 | 16 | export type UpdateAppManifestInput = { 17 | /** 18 | * 테넌트 앱의 ID 19 | */ 20 | appId: ResourceId, 21 | 22 | /** 23 | * 매니페스트 정보 24 | */ 25 | manifest: AppManifest, 26 | }; 27 | 28 | export type UpdateAppManifestResult = { 29 | /** 30 | * 패키지 내부에 31 | */ 32 | updated: boolean, 33 | 34 | /** 35 | * app_id 입력에 따라 대표 주소가 변경될 수 있습니다. 36 | */ 37 | hostname: string, 38 | 39 | /** 40 | * 번들 내에 유효한 mini.json 파일이 포함된 경우 해당 내용이, 41 | * 존재하지 않는 경우 입력한 값이 리턴됩니다. 42 | */ 43 | manifest: AppManifest, 44 | }; 45 | -------------------------------------------------------------------------------- /playground-management-api/lib/src/interface/UploadAppBundle.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ResourceId, 3 | type AppManifest, 4 | } from './common'; 5 | 6 | export interface IPlaygroundManagementAPI { 7 | uploadAppBundle( 8 | input: UploadAppBundleInput, 9 | ): Promise; 10 | } 11 | 12 | export type UploadAppBundleInput = { 13 | /** 14 | * 테넌트 앱의 ID 15 | */ 16 | appId: ResourceId, 17 | 18 | /** 19 | * 컨텐츠 blob 파일 20 | * 반드시 유효한 miniapp package format (현재는 ZIP만 지원) 이여야 합니다. 21 | */ 22 | content: Blob, 23 | 24 | /** 25 | * 지정하는 경우 배포가 바로 생성됩니다 26 | * "live" 는 예약된 deployment name 이고, 지정하면 바로 사이트에 반영됩니다 27 | * 나머지 이름은 {deploymentName}.{appId}.karrotmini.app 으로 배포됩니다. 28 | * 29 | * 배포가 지정되지 않는 경우 이후 bundleId 를 통해 30 | */ 31 | deploymentName: string | null, 32 | 33 | /** 34 | * 패키지 내부에 mini.json 이 포함되어 있지 않는 경우 사용되는 매니페스트 정보입니다. 35 | */ 36 | manifestFallback: AppManifest, 37 | 38 | /** 39 | * 번들에 임의의 태그를 지정할 수 있습니다. 40 | * 빈 문자열은 무시됩니다. 41 | */ 42 | tag: string | null, 43 | }; 44 | 45 | export type UploadAppBundleResult = { 46 | /** 47 | * 번들 내에 유효한 mini.json 파일이 포함된 경우 반환됩니다. 48 | * 없어도 되지만 존재하는 경우 반드시 유효해야하며, 49 | * 존재하는 경우 입력한 manifestFallback 값은 무시됩니다. 50 | */ 51 | manifest: AppManifest | null, 52 | 53 | /** 54 | * 생성된 번들의 리소스 ID 55 | */ 56 | bundleId: ResourceId, 57 | }; 58 | -------------------------------------------------------------------------------- /playground-management-api/lib/src/interface/common.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AppManifestPayload, 3 | } from '@karrotmini/playground-core/src/entities/AppManifest'; 4 | 5 | export type AppManifest = AppManifestPayload; 6 | 7 | /** 8 | * 서비스에서 발급한 크레덴셜 문자열 9 | */ 10 | export type ResourceCredential< 11 | RequireResource extends string, 12 | RequirePermission extends string 13 | > = string & ( 14 | & { __ISSUER__: '@karrotmini/playground-management-api' } 15 | & { __BRAND__: 'ResourceCredential' } 16 | & { __REQUIRE__: [RequireResource, RequirePermission] } 17 | ); 18 | 19 | /** 20 | * Playground 서비스 리소스의 고유 UI 21 | */ 22 | export type ResourceId = string & ( 23 | & { __ISSUER__: '@karrotmini/playground-management-api' } 24 | & { __BRAND__: 'ResourceId' } 25 | ); 26 | 27 | 28 | -------------------------------------------------------------------------------- /playground-management-api/lib/src/interface/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common'; 2 | 3 | export interface IPlaygroundManagementAPI { } 4 | export * from './IssueAppCredential'; 5 | export * from './IssueUserProfileCredential'; 6 | -------------------------------------------------------------------------------- /playground-management-api/lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "moduleResolution": "NodeNext", 5 | "rootDir": "src", 6 | "outDir": "lib" 7 | }, 8 | "include": [ 9 | "src" 10 | ], 11 | "exclude": [ 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /playground-management-api/worker/.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | -------------------------------------------------------------------------------- /playground-management-api/worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@karrotmini/playground-management-api-worker", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "wrangler": "wrangler", 7 | "miniflare": "miniflare" 8 | }, 9 | "dependencies": { 10 | "@karrotmini/cloudflare-hostname-provider": "workspace:^", 11 | "@karrotmini/playground-application": "workspace:^", 12 | "@karrotmini/playground-cloudflare-adapter": "workspace:^", 13 | "@karrotmini/playground-core": "workspace:^", 14 | "@urlpack/base62": "^1.1.0", 15 | "@urlpack/json": "^1.1.0", 16 | "@urlpack/msgpack": "^1.1.0", 17 | "worktop": "next" 18 | }, 19 | "devDependencies": { 20 | "@cloudflare/workers-types": "^3.13.0", 21 | "esbuild": "^0.14.47", 22 | "miniflare": "^2.6.0", 23 | "typescript": "^4.7.4", 24 | "wrangler": "^2.0.15" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /playground-management-api/worker/src/__generated__/schema.graphql: -------------------------------------------------------------------------------- 1 | type App implements Node { 2 | canonicalHost: CustomHost! 3 | deployments: [AppDeployment!]! 4 | id: ID! 5 | liveDeployment: AppDeployment 6 | manifest: AppManifest! 7 | } 8 | 9 | type AppDeployment { 10 | bundle: Bundle! 11 | customHost: CustomHost! 12 | delployedAt: DateTime! 13 | name: String! 14 | } 15 | 16 | type AppManifest { 17 | icon: URL! 18 | name: String! 19 | } 20 | 21 | enum AppPermision { 22 | ADMIN 23 | READ 24 | WRITE 25 | } 26 | 27 | union Bundle = BundleTemplate | BundleUpload 28 | 29 | type BundleTemplate implements Node { 30 | id: ID! 31 | } 32 | 33 | type BundleUpload implements Node { 34 | id: ID! 35 | } 36 | 37 | input CreateAppInput { 38 | appId: String! 39 | name: String! 40 | userProfileId: ID! 41 | } 42 | 43 | type CreateAppResult { 44 | app: App! 45 | customHost: CustomHost! 46 | userProfile: UserProfile! 47 | } 48 | 49 | input CreateUserProfileInput { 50 | _: String 51 | } 52 | 53 | type CreateUserProfileResult { 54 | createApp(input: CreateUserProfileResultCreateAppInput!): CreateUserProfileResultCreateAppResult! 55 | userProfile: UserProfile! 56 | } 57 | 58 | input CreateUserProfileResultCreateAppInput { 59 | appId: String! 60 | name: String! 61 | } 62 | 63 | type CreateUserProfileResultCreateAppResult { 64 | app: App! 65 | customHost: CustomHost! 66 | userProfile: UserProfile! 67 | } 68 | 69 | type CustomHost implements Node { 70 | id: ID! 71 | providerInfo: HostnameProviderInfo! 72 | } 73 | 74 | scalar DateTime 75 | 76 | type HostnameProviderInfo { 77 | healthCheckUrl: URL! 78 | hostname: String! 79 | managementUrl: URL! 80 | url: URL! 81 | } 82 | 83 | type IssueAppCredentialInput { 84 | appId: ID! 85 | permission: AppPermision! 86 | } 87 | 88 | type IssueAppCredentialResult { 89 | credential: String! 90 | } 91 | 92 | type IssueUserProfileCredentialInput { 93 | permission: UserProfilePermision! 94 | userProfileId: ID! 95 | } 96 | 97 | type IssueUserProfileCredentialResult { 98 | credential: String! 99 | } 100 | 101 | type Mutation { 102 | createApp(input: CreateAppInput!): CreateAppResult! 103 | createUserProfile(input: CreateUserProfileInput! = {}): CreateUserProfileResult! 104 | issueAppCredential(input: IssueAppCredentialInput!): IssueAppCredentialResult! 105 | issueUserProfileCredential(input: IssueUserProfileCredentialInput!): IssueUserProfileCredentialResult! 106 | } 107 | 108 | interface Node { 109 | id: ID! 110 | } 111 | 112 | type Query { 113 | node(id: ID!): Node 114 | userProfile(id: ID!): UserProfile 115 | } 116 | 117 | scalar URL 118 | 119 | type UserProfile implements Node { 120 | apps: [App!]! 121 | id: ID! 122 | name: String! 123 | profileImageUrl: URL! 124 | } 125 | 126 | enum UserProfilePermision { 127 | ADMIN 128 | READ 129 | WRITE 130 | } -------------------------------------------------------------------------------- /playground-management-api/worker/src/adapters/MiniControl.ts: -------------------------------------------------------------------------------- 1 | export class MiniControl { 2 | #service: ServiceBinding; 3 | 4 | constructor(config: { 5 | service: ServiceBinding, 6 | }) { 7 | this.#service = config.service; 8 | } 9 | 10 | async validateManagementKey(key: string): Promise { 11 | const url = new URL('http://playground-management-api'); 12 | url.pathname = '/management_key'; 13 | 14 | const response = await this.#service.fetch(url, { 15 | method: 'GET', 16 | }); 17 | if (!response.ok) { 18 | throw new Error('Failed to fetch'); 19 | } 20 | 21 | const upstreamKey = await response.text(); 22 | return key === upstreamKey; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /playground-management-api/worker/src/adapters/PlaygroundBundleStorage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Bundle, 3 | type CustomHost, 4 | } from '@karrotmini/playground-core/src'; 5 | import { 6 | type IBundleStorage, 7 | } from '@karrotmini/playground-application/src'; 8 | 9 | export class PlaygroundBundleStorage 10 | implements IBundleStorage 11 | { 12 | #service: ServiceBinding; 13 | 14 | constructor(config: { 15 | service: ServiceBinding, 16 | }) { 17 | this.#service = config.service; 18 | } 19 | 20 | async connectBundleHost(props: { 21 | bundle: Bundle, 22 | customHost: CustomHost, 23 | }): Promise { 24 | const url = new URL('http://playground'); 25 | url.pathname = `/bundle/${props.bundle.id}/connect_hostname`; 26 | 27 | const form = new FormData(); 28 | form.set('hostname', props.customHost.hostname); 29 | 30 | const response = await this.#service.fetch(url, { 31 | method: 'POST', 32 | body: form, 33 | }); 34 | if (!response.ok) { 35 | throw new Error('Failed to fetch'); 36 | } 37 | // FIXME: Error handling 38 | } 39 | 40 | async uploadBundleContent(props: { 41 | bundle: Bundle, 42 | content: ReadableStream, 43 | }): Promise { 44 | const url = new URL('http://playground'); 45 | url.pathname = `/bundle/${props.bundle.id}/connect_hostname`; 46 | 47 | const form = new FormData(); 48 | const file = new File([], props.bundle.id + '.zip'); 49 | form.set('file', file); 50 | 51 | const response = await this.#service.fetch(url, { 52 | method: 'POST', 53 | body: form, 54 | }); 55 | if (!response.ok) { 56 | throw new Error('Failed to fetch'); 57 | } 58 | // FIXME: Error handling 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /playground-management-api/worker/src/authorization.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Handler, 3 | } from 'worktop'; 4 | import { 5 | AuthorizationError, 6 | PlaygroundResourceAuthorizer, 7 | type AuthorizationState, 8 | } from '@karrotmini/playground-application/src'; 9 | 10 | import { type WorkerContext } from './context'; 11 | import * as Credential from './credential'; 12 | 13 | export function permit(): Handler { 14 | return async function(request, context) { 15 | const authState = await authStateFromHeaders(request.headers, context.bindings.CREDENTIAL_SECRET); 16 | const authorizer = new PlaygroundResourceAuthorizer(authState); 17 | context.authz = authorizer; 18 | } 19 | } 20 | 21 | export async function authStateFromHeaders(headers: Headers, secret: string): Promise { 22 | const header = headers.get('X-Playground-Credential'); 23 | if (!header) { 24 | throw new AuthorizationError(); 25 | } 26 | 27 | return authStateFromCredential( 28 | header as Credential.T, 29 | secret, 30 | ); 31 | } 32 | 33 | export async function authStateFromCredential(credential: Credential.T, secret: string): Promise { 34 | const verificationResult = await Credential.verify({ 35 | crypto, 36 | textEncoder: new TextEncoder(), 37 | credential, 38 | secret, 39 | }); 40 | 41 | if (!verificationResult) { 42 | throw new AuthorizationError(); 43 | } 44 | 45 | const grant: { [level: string]: true } = {}; 46 | for (const level of verificationResult.grant) { 47 | grant[level] = true; 48 | } 49 | 50 | switch (verificationResult.typename) { 51 | case 'App': 52 | case 'UserProfile': { 53 | return { 54 | [verificationResult.typename]: { 55 | [verificationResult.id]: grant, 56 | }, 57 | }; 58 | } 59 | default: { 60 | throw new AuthorizationError('Invalid Credential'); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /playground-management-api/worker/src/context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Handler, 3 | type Context as WorktopContext, 4 | } from 'worktop'; 5 | import { 6 | makeApplicationContext, 7 | NoopEventBus, 8 | ConsoleReporter, 9 | type IExecutorContext, 10 | type IResourceAuthorizer, 11 | } from '@karrotmini/playground-application/src'; 12 | import { 13 | AppRepository, 14 | BundleUploadRepository, 15 | CustomHostRepository, 16 | UserProfileRepository, 17 | } from '@karrotmini/playground-cloudflare-adapter/src'; 18 | import { 19 | CloudflareHostnameProvider, 20 | } from '@karrotmini/cloudflare-hostname-provider/src'; 21 | import { 22 | PlaygroundBundleStorage, 23 | } from './adapters/PlaygroundBundleStorage'; 24 | 25 | export interface WorkerContext extends WorktopContext, IExecutorContext { 26 | bindings: WranglerEnv, 27 | authz: IResourceAuthorizer, 28 | }; 29 | 30 | export type T = WorkerContext; 31 | 32 | export function setup(): Handler { 33 | return async function(_request, context) { 34 | const { authz, bindings } = context; 35 | 36 | const applicationContext = makeApplicationContext({ 37 | // Should be injected before 38 | authz, 39 | 40 | env: { 41 | crypto, 42 | vars: { 43 | HOSTNAME_PATTERN: bindings.HOSTNAME_PATTERN, 44 | }, 45 | secrets: { 46 | CREDENTIAL_SECRET: bindings.CREDENTIAL_SECRET, 47 | }, 48 | }, 49 | 50 | services: { 51 | bundleStorage: new PlaygroundBundleStorage({ 52 | service: bindings.bundleStorage, 53 | }), 54 | hostnameProvider: new CloudflareHostnameProvider({ 55 | fetch, 56 | zoneId: bindings.CLOUDFLARE_CUSTOMHOST_ZONE_ID, 57 | apiToken: bindings.CLOUDFLARE_CUSTOMHOST_ZONE_MANAGEMENT_KEY, 58 | }), 59 | }, 60 | 61 | repos: { 62 | App: new AppRepository({ 63 | service: bindings.playground, 64 | }), 65 | BundleUpload: new BundleUploadRepository({ 66 | service: bindings.playground 67 | }), 68 | CustomHost: new CustomHostRepository({ 69 | service: bindings.playground, 70 | }), 71 | UserProfile: new UserProfileRepository({ 72 | service: bindings.playground, 73 | }), 74 | }, 75 | reporter: new ConsoleReporter(console), 76 | eventBus: new NoopEventBus(), 77 | }); 78 | 79 | context.application = applicationContext; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /playground-management-api/worker/src/credential.ts: -------------------------------------------------------------------------------- 1 | import * as Base62 from '@urlpack/base62'; 2 | import { 3 | makeMessagePackEncoder, 4 | makeMessagePackDecoder, 5 | } from '@urlpack/msgpack'; 6 | import { 7 | type AppID, 8 | type UserProfileID, 9 | } from '@karrotmini/playground-core/src'; 10 | 11 | // Note: authorization header 에 첨부 12 | // e.g. Authorization: Playground-Management-Api-Credential 13 | // 14 | // format: msgpack+base62(payload) + "." + msgpack+HMACSHA256+base62(payload, secret) 15 | // 16 | export type T = string & { __BRAND__: 'Credential' }; 17 | 18 | export class InvalidCredentialError extends Error { 19 | constructor() { 20 | super('Invalid credential format'); 21 | } 22 | } 23 | 24 | interface ITextEncoder { 25 | encode(input?: string): Uint8Array; 26 | } 27 | 28 | export type Payload = SigningInfo & ( 29 | | AppCredentialPayload 30 | | UserProfileCredentialPayload 31 | ); 32 | 33 | export type SigningInfo = { 34 | via: string, 35 | at: number, 36 | }; 37 | 38 | type UserProfileCredentialPayload = { 39 | typename: 'UserProfile', 40 | id: UserProfileID, 41 | grant: Array<'READ' | 'WRITE' | 'ADMIN'>, 42 | }; 43 | 44 | type AppCredentialPayload = { 45 | typename: 'App', 46 | id: AppID, 47 | grant: Array<'READ' | 'WRITE' | 'ADMIN'>, 48 | }; 49 | 50 | async function importSingingKey(props: { 51 | textEncoder: ITextEncoder, 52 | crypto: Crypto, 53 | secret: string, 54 | }): Promise { 55 | const key = await props.crypto.subtle.importKey( 56 | 'raw', 57 | props.textEncoder.encode(props.secret), 58 | { 59 | name: 'HMAC', 60 | hash: { name: 'SHA-256' }, 61 | }, 62 | true, 63 | ['sign', 'verify'], 64 | ); 65 | return key; 66 | } 67 | 68 | export async function sign(props: { 69 | payload: Payload, 70 | textEncoder: ITextEncoder, 71 | crypto: Crypto, 72 | secret: string, 73 | }): Promise { 74 | const msgpackEncoder = makeMessagePackEncoder(); 75 | const key = await importSingingKey(props); 76 | const payload = msgpackEncoder.encode(props.payload); 77 | const signatureData = await props.crypto.subtle.sign('HMAC', key, payload); 78 | const signature = new Uint8Array(signatureData); 79 | return `${Base62.encode(payload)}.${Base62.encode(signature)}` as T; 80 | } 81 | 82 | export function parse(credential: T): { 83 | payload: Payload, 84 | payloadSource: Uint8Array, 85 | signature: Uint8Array, 86 | } { 87 | const [payloadPart, signaturePart] = credential.split('.'); 88 | if (!(payloadPart && signaturePart)) { 89 | throw new InvalidCredentialError(); 90 | } 91 | 92 | const msgpackDecoder = makeMessagePackDecoder(); 93 | 94 | const payloadSource = Base62.decode(payloadPart); 95 | const payload = msgpackDecoder.decode(payloadSource) as Payload; 96 | if (typeof payload !== 'object') { 97 | throw new InvalidCredentialError(); 98 | } 99 | 100 | const signature = Base62.decode(signaturePart); 101 | 102 | return { 103 | payload, 104 | payloadSource, 105 | signature, 106 | }; 107 | } 108 | 109 | export async function verify(props: { 110 | credential: T, 111 | textEncoder: ITextEncoder, 112 | crypto: Crypto, 113 | secret: string, 114 | }): Promise { 115 | try { 116 | const { payload, payloadSource, signature } = parse(props.credential); 117 | const key = await importSingingKey(props); 118 | const result = await props.crypto.subtle.verify('HMAC', key, signature, payloadSource); 119 | return result ? payload : null; 120 | } catch { 121 | return null; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /playground-management-api/worker/src/gateway.ts: -------------------------------------------------------------------------------- 1 | import { type Handler } from 'worktop'; 2 | import { reply } from 'worktop/response'; 3 | 4 | import { type WorkerContext } from './context'; 5 | import { MiniControl } from './adapters/MiniControl'; 6 | 7 | export function trust(): Handler { 8 | return async function(request, context) { 9 | const control = new MiniControl({ 10 | service: context.bindings.minictl, 11 | }); 12 | 13 | const pattern = /X-Playground-Management-Key (?\w+)/i; 14 | const header = request.headers.get('Authorization'); 15 | const mangementKey = header?.match(pattern)?.groups?.key; 16 | 17 | if (!mangementKey) { 18 | return reply(401, 'operation not permitted'); 19 | } 20 | 21 | const valid = await control.validateManagementKey(mangementKey); 22 | if (!valid) { 23 | return reply(401, 'operation not permitted'); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /playground-management-api/worker/src/resolvers/Mutation.issueAppCredential.graphql: -------------------------------------------------------------------------------- 1 | enum AppPermision { 2 | READ 3 | WRITE 4 | ADMIN 5 | } 6 | 7 | type IssueAppCredentialInput { 8 | permission: AppPermision! 9 | appId: ID! 10 | } 11 | 12 | type IssueAppCredentialResult { 13 | credential: String! 14 | } 15 | 16 | type Mutation { 17 | issueAppCredential( 18 | input: IssueAppCredentialInput! 19 | ): IssueAppCredentialResult! 20 | } 21 | -------------------------------------------------------------------------------- /playground-management-api/worker/src/resolvers/Mutation.issueAppCredential.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AppID, 3 | } from '@karrotmini/playground-core/src'; 4 | import { 5 | Resource, 6 | ResourceLoadingError, 7 | } from '@karrotmini/playground-application/src'; 8 | import { 9 | type MutationResolvers, 10 | } from '@karrotmini/playground-management-api-worker/src/__generated__/types'; 11 | 12 | import * as Credential from '../credential'; 13 | 14 | export const issueAppCredential: MutationResolvers['issueAppCredential'] = async ( 15 | _root, 16 | args, 17 | { application, bindings }, 18 | ) => { 19 | const resource = Resource.fromGlobalId(args.input.appId); 20 | 21 | const appId = AppID(resource.id); 22 | const app = await application.repos.App.aggregate(appId); 23 | if (!app) { 24 | throw new ResourceLoadingError(resource); 25 | } 26 | 27 | const credential = await Credential.sign({ 28 | crypto, 29 | textEncoder: new TextEncoder(), 30 | payload: { 31 | typename: 'App', 32 | id: appId, 33 | grant: [args.input.permission], 34 | via: '@karrotmini/playground-management-api-worker', 35 | at: Date.now(), 36 | }, 37 | secret: bindings.CREDENTIAL_SECRET, 38 | }); 39 | 40 | return { 41 | credential, 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /playground-management-api/worker/src/resolvers/Mutation.issueUserProfileCredential.graphql: -------------------------------------------------------------------------------- 1 | enum UserProfilePermision { 2 | READ 3 | WRITE 4 | ADMIN 5 | } 6 | 7 | type IssueUserProfileCredentialInput { 8 | permission: UserProfilePermision! 9 | userProfileId: ID! 10 | } 11 | 12 | type IssueUserProfileCredentialResult { 13 | credential: String! 14 | } 15 | 16 | type Mutation { 17 | issueUserProfileCredential( 18 | input: IssueUserProfileCredentialInput! 19 | ): IssueUserProfileCredentialResult! 20 | } 21 | -------------------------------------------------------------------------------- /playground-management-api/worker/src/resolvers/Mutation.issueUserProfileCredential.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type MutationResolvers, 3 | } from '@karrotmini/playground-management-api-worker/src/__generated__/types'; 4 | 5 | export const issueUserProfileCredential: MutationResolvers['issueUserProfileCredential'] = async ( 6 | _root, 7 | args, 8 | { 9 | repos, 10 | mutator, 11 | }, 12 | ) => { 13 | throw new Error('not implemented'); 14 | }; 15 | -------------------------------------------------------------------------------- /playground-management-api/worker/src/resolvers/Mutation.ts: -------------------------------------------------------------------------------- 1 | export * from './Mutation.issueAppCredential'; 2 | export * from './Mutation.issueUserProfileCredential'; 3 | -------------------------------------------------------------------------------- /playground-management-api/worker/src/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | export * as Mutation from './Mutation'; 2 | -------------------------------------------------------------------------------- /playground-management-api/worker/src/usecases/IssueAppCredential.generated.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import type * as Types from '../__generated__/types'; 3 | 4 | import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; 5 | export type IssueAppCredentialMutationVariables = Types.Exact<{ 6 | appId: Types.InputMaybe; 7 | permission: Types.AppPermision; 8 | }>; 9 | 10 | 11 | export type IssueAppCredentialMutation = { __typename: 'Mutation', issueAppCredential: { __typename: 'IssueAppCredentialResult', credential: string } }; 12 | 13 | 14 | export const IssueAppCredentialDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"IssueAppCredential"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"permission"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AppPermision"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"issueAppCredential"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"permission"},"value":{"kind":"Variable","name":{"kind":"Name","value":"permission"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"credential"}}]}}]}}]} as unknown as DocumentNode; -------------------------------------------------------------------------------- /playground-management-api/worker/src/usecases/IssueAppCredential.graphql: -------------------------------------------------------------------------------- 1 | mutation IssueAppCredential( 2 | $appId: ID 3 | $permission: AppPermision! 4 | ) { 5 | issueAppCredential(input: { 6 | appId: $appId 7 | permission: $permission 8 | }) { 9 | credential 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /playground-management-api/worker/src/usecases/IssueUserProfileCredential.generated.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import type * as Types from '../__generated__/types'; 3 | 4 | import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; 5 | export type IssueUserProfileCredentialMutationVariables = Types.Exact<{ 6 | userProfileId: Types.InputMaybe; 7 | permission: Types.UserProfilePermision; 8 | }>; 9 | 10 | 11 | export type IssueUserProfileCredentialMutation = { __typename: 'Mutation', issueUserProfileCredential: { __typename: 'IssueUserProfileCredentialResult', credential: string } }; 12 | 13 | 14 | export const IssueUserProfileCredentialDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"IssueUserProfileCredential"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userProfileId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"permission"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UserProfilePermision"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"issueUserProfileCredential"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"userProfileId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userProfileId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"permission"},"value":{"kind":"Variable","name":{"kind":"Name","value":"permission"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"credential"}}]}}]}}]} as unknown as DocumentNode; -------------------------------------------------------------------------------- /playground-management-api/worker/src/usecases/IssueUserProfileCredential.graphql: -------------------------------------------------------------------------------- 1 | mutation IssueUserProfileCredential( 2 | $userProfileId: ID 3 | $permission: UserProfilePermision! 4 | ) { 5 | issueUserProfileCredential(input: { 6 | userProfileId: $userProfileId 7 | permission: $permission 8 | }) { 9 | credential 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /playground-management-api/worker/src/usecases/index.ts: -------------------------------------------------------------------------------- 1 | export * from './IssueAppCredential.generated'; 2 | export * from './IssueUserProfileCredential.generated'; 3 | -------------------------------------------------------------------------------- /playground-management-api/worker/src/worker.ts: -------------------------------------------------------------------------------- 1 | import { type DocumentNode } from 'graphql'; 2 | import { Router, compose } from 'worktop'; 3 | import { reply } from 'worktop/response'; 4 | import * as CORS from 'worktop/cors'; 5 | import { start } from 'worktop/cfw'; 6 | import * as Cache from 'worktop/cfw.cache'; 7 | import { makeJsonDecoder } from '@urlpack/json'; 8 | import { Executor } from '@karrotmini/playground-application/src'; 9 | 10 | import * as Context from './context'; 11 | import * as Gateway from './gateway'; 12 | import * as Authz from './authorization'; 13 | 14 | import typeDefs from './__generated__/schema'; 15 | import * as resolvers from './resolvers'; 16 | import { 17 | IssueAppCredentialDocument, 18 | IssueUserProfileCredentialDocument, 19 | } from './usecases'; 20 | 21 | const operationDict: Record = { 22 | IssueAppCredential: IssueAppCredentialDocument, 23 | IssueUserProfileCredential: IssueUserProfileCredentialDocument, 24 | }; 25 | 26 | const API = new Router(); 27 | 28 | API.prepare = compose( 29 | CORS.preflight(), 30 | Gateway.trust(), 31 | Authz.permit(), 32 | Context.setup(), 33 | Cache.sync(), 34 | ); 35 | 36 | API.add('POST', '/api/graphql/:operation', async (request, context) => { 37 | const executor = new Executor({ 38 | context, 39 | additionalTypeDefs: typeDefs, 40 | additionalResolvers: resolvers, 41 | }); 42 | 43 | const operation = operationDict[context.params.operation]; 44 | if (!operation) { 45 | return reply(404, 'Unknown operation'); 46 | } 47 | 48 | const variablesParam = new URL(request.url).searchParams.get('variables'); 49 | if (!variablesParam) { 50 | return reply(400, 'Missing variables'); 51 | } 52 | 53 | const decoder = makeJsonDecoder(); 54 | try { 55 | var variables = decoder.decode(variablesParam) as Record; 56 | } catch { 57 | return reply(400, 'Invalid variables'); 58 | } 59 | if (!variables || typeof variables !== 'object') { 60 | return reply(400, 'Invalid variables'); 61 | } 62 | 63 | const result = await executor.execute(operation, variables); 64 | return reply(200, result); 65 | }); 66 | 67 | export default start(API.run); 68 | -------------------------------------------------------------------------------- /playground-management-api/worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"] 5 | }, 6 | "include": [ 7 | "wrangler.d.ts", 8 | "src/**/*" 9 | ], 10 | "exclude": [ 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /playground-management-api/worker/wrangler.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ServiceBinding { 4 | fetch: typeof fetch; 5 | } 6 | 7 | declare interface WranglerEnv { 8 | // vars 9 | HOSTNAME_PATTERN: string; 10 | CLOUDFLARE_CUSTOMHOST_ZONE_ID: string; 11 | 12 | // secrets 13 | CREDENTIAL_SECRET: string; 14 | CLOUDFLARE_CUSTOMHOST_ZONE_MANAGEMENT_KEY: string; 15 | 16 | // service bindings 17 | minictl: ServiceBinding; 18 | playground: ServiceBinding; 19 | bundleStorage: ServiceBinding; 20 | 21 | [key: string]: any; 22 | } 23 | -------------------------------------------------------------------------------- /playground-management-api/worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "karrotmini-playground-management-api" 2 | workers_dev = true 3 | compatibility_date = "2022-05-22" 4 | compatibility_flags = [ 5 | "url_standard", 6 | ] 7 | account_id = "aad5c82543cd1f267b89737d0f56405e" 8 | 9 | # main = "./dist/worker.mjs" 10 | 11 | [vars] 12 | HOSTNAME_PATTERN = "*.karrotmini.app" 13 | 14 | [[services]] 15 | binding = "minictl" 16 | service = "minictl" 17 | 18 | [[services]] 19 | binding = "playground" 20 | service = "karrotmini-playground-adapter" 21 | 22 | [[services]] 23 | binding = "bundleStorage" 24 | service = "karrotmini-playground-bundle-storage" 25 | 26 | [miniflare.mounts] 27 | minictl = "../../minictl/worker" 28 | karrotmini-playground-adapter = "../../playground-cloudflare-adapter/worker" 29 | karrotmini-playground-bundle-storage = "../../playground-bundle-storage" 30 | 31 | [build] 32 | watch_dir = "src" 33 | command = "yarn esbuild src/worker.ts --bundle --minify --outfile=dist/worker.mjs --format=esm" 34 | 35 | # Deprecated in Wrangler v2, But still required for miniflare 36 | [build.upload] 37 | format = "modules" 38 | dir = "./dist" 39 | main = "./worker.mjs" 40 | -------------------------------------------------------------------------------- /playground-remix-ui/README.md: -------------------------------------------------------------------------------- 1 | # Playground Remix UI 2 | -------------------------------------------------------------------------------- /playground-remix-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@karrotmini/playground-remix-ui", 3 | "version": "0.0.0", 4 | "private": true 5 | } 6 | -------------------------------------------------------------------------------- /playground-webapp-controller/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | wasm-pack.log 4 | build/ 5 | -------------------------------------------------------------------------------- /playground-webapp-controller/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "playground-webapp-controller" 3 | version = "0.0.1" 4 | authors = ["Hyeseong Kim "] 5 | edition = "2021" 6 | 7 | [lib] 8 | crate-type = ["cdylib", "rlib"] 9 | 10 | [features] 11 | default = ["console_error_panic_hook"] 12 | 13 | [dependencies] 14 | cfg-if = "0.1.2" 15 | worker = "0.0.9" 16 | serde_json = "1.0.67" 17 | regex = "1" 18 | mime = "0.3.16" 19 | mime_guess = "2.0.4" 20 | 21 | zip = { version = "0.6.2", default-features = false, features = ["deflate"] } 22 | 23 | # The `console_error_panic_hook` crate provides better debugging of panics by 24 | # logging them with `console.error`. This is great for development, but requires 25 | # all the `std::fmt` and `std::panicking` infrastructure, so isn't great for 26 | # code size when deploying. 27 | console_error_panic_hook = { version = "0.1.1", optional = true } 28 | -------------------------------------------------------------------------------- /playground-webapp-controller/README.md: -------------------------------------------------------------------------------- 1 | # Service: Karrotmini Webapp Controller 2 | -------------------------------------------------------------------------------- /playground-webapp-controller/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@karrotmini/playground-webapp-controller", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "miniflare": "miniflare", 7 | "wrangler": "wrangler" 8 | }, 9 | "devDependencies": { 10 | "miniflare": "^2.6.0", 11 | "wrangler": "^2.0.22" 12 | }, 13 | "packageManager": "yarn@3.2.1" 14 | } 15 | -------------------------------------------------------------------------------- /playground-webapp-controller/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::io::{Cursor, Read, Seek}; 2 | 3 | use worker::{*, kv::KvStore}; 4 | use zip::read::ZipArchive; 5 | 6 | mod utils; 7 | 8 | #[event(fetch)] 9 | pub async fn main(req: Request, env: Env, _ctx: worker::Context) -> Result { 10 | utils::set_panic_hook(); 11 | 12 | let url = req.url()?; 13 | let hostname = url.host_str().unwrap(); 14 | 15 | // FIXME: Bundle Storage 구현을 playground-bundle-storage 서비스 바인딩으로 분리 16 | // blocked by https://github.com/cloudflare/workers-rs/pull/183 17 | let kv = KvStore::from_this(&env, "KV_BUNDLE_STORE")?; 18 | 19 | if let Some(index) = kv.get(hostname).text().await? { 20 | if let Some(bytes) = kv.get(index.as_str()).bytes().await? { 21 | let cursor = Cursor::new(bytes); 22 | let mut archive = ZipArchive::new(cursor).unwrap(); 23 | 24 | let path = url.path(); 25 | let path = match &path[1..path.len()] { 26 | "" => "index.html", 27 | path => path, 28 | }; 29 | if let Some(response) = read_archive(&mut archive, path) { 30 | return Ok(response); 31 | } 32 | } 33 | } 34 | 35 | Response::error("Not found", 404) 36 | } 37 | 38 | fn read_archive(archive: &mut ZipArchive, path: &str) -> Option { 39 | match archive.by_name(path).map(|mut file| { 40 | let mime = mime_guess::from_path(path) 41 | .first() 42 | .unwrap_or(mime::APPLICATION_OCTET_STREAM); 43 | 44 | let mut headers = Headers::new(); 45 | headers.append("Content-Type", mime.to_string().as_str()).unwrap(); 46 | 47 | let mut buf: Vec = Vec::new(); 48 | file.read_to_end(&mut buf).unwrap(); 49 | 50 | Response::from_bytes(buf) 51 | .map(|resp| resp.with_headers(headers)) 52 | .unwrap() 53 | }) { 54 | Ok(response) => Some(response), 55 | Err(..) => None, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /playground-webapp-controller/src/utils.rs: -------------------------------------------------------------------------------- 1 | use cfg_if::cfg_if; 2 | 3 | cfg_if! { 4 | // https://github.com/rustwasm/console_error_panic_hook#readme 5 | if #[cfg(feature = "console_error_panic_hook")] { 6 | extern crate console_error_panic_hook; 7 | pub use self::console_error_panic_hook::set_once as set_panic_hook; 8 | } else { 9 | #[inline] 10 | pub fn set_panic_hook() {} 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /playground-webapp-controller/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "karrotmini-playground-webapp-controller" 2 | workers_dev = true 3 | compatibility_date = "2022-07-18" 4 | routes = [ 5 | "*.karrotmini.app/*", 6 | "webapp-controller.internal.karrotmini.dev/*" 7 | ] 8 | 9 | [vars] 10 | WORKERS_RS_VERSION = "0.0.9" 11 | 12 | # FIXME: playground-bundle-storage 서비스 바인딩으로 대체하기 13 | # Blocked by https://github.com/cloudflare/workers-rs/pull/183 14 | [[kv_namespaces]] 15 | binding = "KV_BUNDLE_STORAGE" 16 | id = "" 17 | preview_id = "" 18 | 19 | [build] 20 | command = "worker-build --release" 21 | 22 | [build.upload] 23 | dir = "build/worker" 24 | format = "modules" 25 | main = "./shim.mjs" 26 | 27 | [[build.upload.rules]] 28 | globs = ["**/*.wasm"] 29 | type = "CompiledWasm" 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "strict": true, 7 | "skipLibCheck": true, 8 | "composite": true, 9 | "incremental": true, 10 | "forceConsistentCasingInFileNames": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | }, 6 | }); 7 | --------------------------------------------------------------------------------