From d6f2a829592e4f0c4f1a223681eff6295e2b7a95 Mon Sep 17 00:00:00 2001 From: Kinflou Date: Wed, 2 Sep 2026 17:08:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(ts):=20lib=20mode=20=E2=80=94=20emit=20an?= =?UTF-8?q?=20npm=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `comline generate --mode lib --target typescript` now produces an installable package instead of erroring: - package.json — name (lowercased) + version from PackageMeta, "type": "module", an exports map, a `build: tsc` script, and "@comline/runtime": "^0.1.0" as a dependency when any schema has a protocol (omitted otherwise). - tsconfig.json — NodeNext, declaration, outDir dist. - src/index.ts — an `export * from "./.js"` barrel. - src/.ts — the same per-schema output as `code` mode. Nested namespaces are rejected for now (a `src/` tree — a follow-up shared with the Rust generator). `@comline/runtime` is not on npm yet, so a generated package needs it linked / from a local registry until it publishes; the generator's RUNTIME_VERSION const carries the intended range. Verified by hand: a generated package `tsc`-builds clean against the linked runtime. --- README.md | 12 ++-- codegen/src/generator.rs | 134 ++++++++++++++++++++++++++++++++++---- codegen/src/lib.rs | 7 +- codegen/tests/generate.rs | 44 ++++++++++++- 4 files changed, 174 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index e79f0a5..992ddf2 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ The **TypeScript target** for [Comline](https://github.com/ComlineProject) — o repo per language, holding that language's codegen, libgen, runtime and std-extra together. -Today: `codegen/` (`comline-codegen-typescript`, the `code`-mode generator, -extracted from `ComlineProject/generation`) and `runtime/` (`@comline/runtime`, -the contract layer so far). `lib` mode, the rest of the runtime, and std-extra -follow. +Today: `codegen/` (`comline-codegen-typescript` — `code` and `lib` modes, +extracted from `ComlineProject/generation`) and `runtime/` (`@comline/runtime` +— contract, framing, transport, `Client` / `Server`). std-extra follows; the +runtime is not published to npm yet. ## `codegen/` @@ -19,6 +19,10 @@ per enum, and per `protocol` the full RPC shape against `@comline/runtime` — a package default). It depends on `comline-codegen` (the language-neutral contract + `Registry`) and `comline-core` (the IR), both by git rev. +`code` mode writes bare `.ts`; `lib` mode wraps them in an npm +package — `package.json` (declaring `@comline/runtime`), `tsconfig.json`, and a +`src/index.ts` barrel. + `register(&mut Registry)` contributes the generator under `typescript` / `ts` at version `5.0`; the Comline CLI composes it into its `Registry` at startup. diff --git a/codegen/src/generator.rs b/codegen/src/generator.rs index a5b733e..931b8cf 100644 --- a/codegen/src/generator.rs +++ b/codegen/src/generator.rs @@ -1,4 +1,4 @@ -//! TypeScript `code`-mode generation: frozen IR -> `.ts` source. +//! TypeScript generation: frozen IR -> `.ts` source. //! //! Per schema file: //! - `struct` -> `export interface` @@ -11,7 +11,9 @@ //! `Dispatcher` (`implements Dispatch`), a `Client`, and a //! `serve` helper. Framing follows `@framing` / the package default. //! -//! `lib` mode (an npm package) is not built yet. See design/generation.md. +//! `code` mode emits bare `.ts`; `lib` mode adds a `package.json` +//! (declaring `@comline/runtime`), a `tsconfig.json`, and a `src/index.ts` +//! barrel. See design/generation.md. use std::collections::HashMap; use std::path::PathBuf; @@ -22,22 +24,126 @@ use comline_core::schema::ir::frozen::unit::FrozenUnit; use eyre::{bail, Result}; -use comline_codegen::{GenRequest, GeneratedFile, Mode}; +use comline_codegen::{GenRequest, GeneratedFile, Mode, PackageMeta}; + +/// The npm package generated RPC code imports from. Not published yet — a +/// generated `lib` package declares this range and expects it resolvable +/// (`npm link`, a local registry, or a future publish). See +/// `ComlineProject/comline-typescript/runtime`. +const RUNTIME_PACKAGE: &str = "@comline/runtime"; +const RUNTIME_VERSION: &str = "^0.1.0"; pub fn generate_typescript(req: &GenRequest) -> Result> { - if req.mode == Mode::Lib { - bail!("typescript lib mode is not implemented yet (de-rot G2)"); + let default_framing = req.default_framing.as_deref(); + + match req.mode { + Mode::Code => Ok(req + .schemas + .iter() + .map(|(namespace, units)| GeneratedFile { + path: PathBuf::from(format!("{namespace}.ts")), + contents: schema_source(units, default_framing), + }) + .collect()), + + Mode::Lib => { + require_flat_namespaces(req.schemas)?; + + let has_protocol = req + .schemas + .iter() + .any(|(_, units)| units.iter().any(|u| matches!(u, FrozenUnit::Protocol { .. }))); + + let mut files = vec![ + GeneratedFile { + path: PathBuf::from("package.json"), + contents: package_json(&req.package, has_protocol), + }, + GeneratedFile { + path: PathBuf::from("tsconfig.json"), + contents: TSCONFIG.to_string(), + }, + GeneratedFile { + path: PathBuf::from("src/index.ts"), + contents: index_ts(req.schemas), + }, + ]; + for (namespace, units) in req.schemas { + files.push(GeneratedFile { + path: PathBuf::from(format!("src/{namespace}.ts")), + contents: schema_source(units, default_framing), + }); + } + Ok(files) + } } +} - let default_framing = req.default_framing.as_deref(); - Ok(req - .schemas - .iter() - .map(|(namespace, units)| GeneratedFile { - path: PathBuf::from(format!("{namespace}.ts")), - contents: schema_source(units, default_framing), - }) - .collect()) +/// `lib` mode emits a flat `export * from "./.js"` list; a `/`-joined +/// namespace would need a nested `src/` tree — a follow-up, shared with the +/// Rust generator. +fn require_flat_namespaces(schemas: &[(String, Vec)]) -> Result<()> { + for (namespace, _) in schemas { + if namespace.contains('/') { + bail!( + "typescript lib mode does not support nested namespaces yet \ + (namespace `{namespace}`)" + ); + } + } + Ok(()) +} + +const TSCONFIG: &str = "{\n\ + \x20 \"compilerOptions\": {\n\ + \x20 \"target\": \"ES2022\",\n\ + \x20 \"module\": \"NodeNext\",\n\ + \x20 \"moduleResolution\": \"NodeNext\",\n\ + \x20 \"outDir\": \"dist\",\n\ + \x20 \"rootDir\": \"src\",\n\ + \x20 \"declaration\": true,\n\ + \x20 \"strict\": true,\n\ + \x20 \"skipLibCheck\": true\n\ + \x20 },\n\ + \x20 \"include\": [\"src/**/*.ts\"]\n\ + }\n"; + +fn package_json(pkg: &PackageMeta, has_protocol: bool) -> String { + let name = pkg.name.to_lowercase(); + let dep = if has_protocol { + format!("\n \"dependencies\": {{\n \"{RUNTIME_PACKAGE}\": \"{RUNTIME_VERSION}\"\n }},") + } else { + String::new() + }; + format!( + "{{\n\ + \x20 \"name\": \"{name}\",\n\ + \x20 \"version\": \"{}\",\n\ + \x20 \"type\": \"module\",\n\ + \x20 \"exports\": {{\n\ + \x20 \".\": {{\n\ + \x20 \"types\": \"./dist/index.d.ts\",\n\ + \x20 \"import\": \"./dist/index.js\"\n\ + \x20 }}\n\ + \x20 }},\n\ + \x20 \"files\": [\"dist\", \"src\"],\n\ + \x20 \"scripts\": {{\n\ + \x20 \"build\": \"tsc\"\n\ + \x20 }},{dep}\n\ + \x20 \"devDependencies\": {{\n\ + \x20 \"typescript\": \"^5.6.0\"\n\ + \x20 }}\n\ + }}\n", + pkg.version + ) +} + +fn index_ts(schemas: &[(String, Vec)]) -> String { + let mut s = String::from("// Generated by Comline\n\n"); + for (namespace, _) in schemas { + s.push_str(&format!("export * from \"./{namespace}.js\";\n")); + } + s } fn schema_source(units: &[FrozenUnit], default_framing: Option<&str>) -> String { diff --git a/codegen/src/lib.rs b/codegen/src/lib.rs index 29a91d0..d0a5504 100644 --- a/codegen/src/lib.rs +++ b/codegen/src/lib.rs @@ -1,8 +1,11 @@ -//! TypeScript code generator. `code` mode only: `export interface` per struct / +//! TypeScript code generator. Per schema: `export interface` per struct / //! `error` (+ a `Error` throwable), `export enum` (string values) per //! enum, and per `protocol` the full RPC shape against `@comline/runtime` — an //! `IR_HASH`, params interfaces, a provider interface, a `Dispatcher`, a -//! `Client`, and a `serve` helper. `lib` mode is not implemented. +//! `Client`, and a `serve` helper. +//! +//! `code` mode emits bare `.ts` files; `lib` mode wraps them in an +//! npm package (`package.json` + `tsconfig.json` + `src/index.ts` barrel). //! See `design/generation.md`. mod generator; diff --git a/codegen/tests/generate.rs b/codegen/tests/generate.rs index 376de76..043bb60 100644 --- a/codegen/tests/generate.rs +++ b/codegen/tests/generate.rs @@ -266,10 +266,48 @@ fn generated_chat_matches_the_runtime_test_fixture() { } #[test] -fn lib_mode_is_not_implemented() { - let schemas = vec![("account".to_string(), vec![])]; +fn lib_mode_emits_an_npm_package() { + let schemas = vec![ + ("chat".to_string(), chat_units()), + ("billing".to_string(), vec![]), + ]; + let files = generate_typescript(&lib_req(&schemas)).unwrap(); + let by_path = + |p: &str| &files.iter().find(|f| f.path.to_str().unwrap() == p).unwrap().contents; + + let pkg = by_path("package.json"); + assert!(pkg.contains("\"name\": \"chat\"")); + assert!(pkg.contains("\"version\": \"0.3.0\"")); + assert!(pkg.contains("\"type\": \"module\"")); + assert!(pkg.contains("\"@comline/runtime\": \"^0.1.0\"")); // a protocol pulls it in + + assert!(by_path("tsconfig.json").contains("\"NodeNext\"")); + + let index = by_path("src/index.ts"); + assert!(index.contains("export * from \"./chat.js\";")); + assert!(index.contains("export * from \"./billing.js\";")); + + assert!(by_path("src/chat.ts").contains("export class ChatClient {")); + assert!(files.iter().any(|f| f.path.to_str().unwrap() == "src/billing.ts")); +} + +#[test] +fn lib_mode_omits_the_runtime_dep_without_a_protocol() { + let schemas = vec![("data".to_string(), vec![])]; + let files = generate_typescript(&lib_req(&schemas)).unwrap(); + let pkg = &files + .iter() + .find(|f| f.path.to_str().unwrap() == "package.json") + .unwrap() + .contents; + assert!(!pkg.contains("@comline/runtime")); +} + +#[test] +fn lib_mode_rejects_nested_namespaces() { + let schemas = vec![("account/user".to_string(), vec![])]; let err = generate_typescript(&lib_req(&schemas)) .unwrap_err() .to_string(); - assert!(err.contains("lib mode")); + assert!(err.contains("nested namespaces")); }