@marcelsud/keel.ts 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcelo Santos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,110 @@
1
- # Temporary Holding Version
1
+ # @marcelsud/keel.ts
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Use Keel from TypeScript to write services, call typed APIs, store data, publish events, and run background jobs.
4
+
5
+ ## Install
6
+
7
+ Use Node.js 22+ or Bun 1.3+:
8
+
9
+ ```sh
10
+ npm install @marcelsud/keel.ts
11
+ ```
12
+
13
+ Or with Bun:
14
+
15
+ ```sh
16
+ bun add @marcelsud/keel.ts
17
+ ```
18
+
19
+ You need a running Keel environment. For the standalone storage example below, install the [Keel CLI](https://github.com/marcelsud/keel#install-the-cli) and run `keel` in another terminal first. To run a service with `keel dev`, configure its startup command in `keel.json`, for example `{ "command": ["bun", "run", "main.ts"] }`.
20
+
21
+ ## Connect and store data
22
+
23
+ ```ts
24
+ import { createKeel } from "@marcelsud/keel.ts";
25
+
26
+ const app = await createKeel({
27
+ name: "notes",
28
+ url: process.env.KEEL_URL,
29
+ token: process.env.KEEL_TOKEN,
30
+ });
31
+
32
+ try {
33
+ const id = await app.collection.insert("notes", { body: "Hello" });
34
+ console.log(await app.collection.get("notes", id));
35
+ } finally {
36
+ await app.stop();
37
+ }
38
+ ```
39
+
40
+ ## Call a typed service
41
+
42
+ Install the [Keel CLI](https://github.com/marcelsud/keel#install-the-cli). Its release bundle includes the generators; TypeScript generation uses your Node.js runtime.
43
+
44
+ If you own the service, put its contracts under `proto/` and run:
45
+
46
+ ```sh
47
+ keel generate
48
+ ```
49
+
50
+ If you consume a service from a neighboring checkout, run in your consumer project:
51
+
52
+ ```sh
53
+ keel use ../notes
54
+ ```
55
+
56
+ Both commands generate into `generated/` without a release or running service. `use` also saves the local dependency in `keel.json`. Run `keel generate` again after changes, or use [keel dev](https://github.com/marcelsud/keel#develop-before-releasing) to regenerate and restart your service automatically.
57
+
58
+ Import the generated contract to get completion and type checking for its methods, requests, and responses. For `proto/notes/v2/notes.proto`, the generated source is `generated/notes/v2/notes_keel.ts`; use a `.js` import for your compiled application:
59
+
60
+ ```ts
61
+ import { createKeel } from "@marcelsud/keel.ts";
62
+ import { NotesServiceContract } from "./generated/notes/v2/notes_keel.js";
63
+
64
+ const app = await createKeel({
65
+ name: "notes-client",
66
+ url: process.env.KEEL_URL,
67
+ token: process.env.KEEL_TOKEN,
68
+ });
69
+
70
+ try {
71
+ const notes = app.client(NotesServiceContract);
72
+ const note = await notes.createNote({
73
+ content: { text: "Hello from TypeScript" },
74
+ });
75
+ console.log(note.id);
76
+ } finally {
77
+ await app.stop();
78
+ }
79
+ ```
80
+
81
+ Contracts belong to your project. See the [complete service example](https://github.com/marcelsud/keel/blob/main/examples/contracts/typescript.ts) for service registration, validation, and version migration.
82
+
83
+ ## Publish this SDK
84
+
85
+ From a checkout of the Keel repository, install the development dependencies using the repository's setup instructions. Publishing also requires Go 1.26+ and Node.js 24+ to run the cross-language examples in the tests.
86
+
87
+ Run these commands from `sdks/typescript`:
88
+
89
+ ```sh
90
+ npm login
91
+ npm publish --dry-run
92
+ npm publish
93
+ ```
94
+
95
+ Publishing runs the tests and builds the package. Packing with `npm pack` also builds it. Consumers receive JavaScript, TypeScript declarations, this README, and the MIT license.
96
+
97
+ For GitHub Actions, configure an [npm trusted publisher](https://docs.npmjs.com/trusted-publishers/) for this package:
98
+
99
+ - GitHub owner: `marcelsud`
100
+ - Repository: `keel`
101
+ - Workflow filename: `publish-npm.yml`
102
+ - Allow direct publishing with `npm publish`.
103
+
104
+ For a new package, make the first publish manually, then configure its trusted publisher. The workflow uses OIDC authentication.
105
+
106
+ Set the SDK version in `package.json`, then push a matching tag such as `keel.ts-v0.1.0`. You can also run **Publish TypeScript SDK** manually from GitHub Actions. Stable versions publish to `latest`; prerelease versions publish to `next` in the workflow. For a manual prerelease, use `npm publish --tag next`.
107
+
108
+ ## License
109
+
110
+ [MIT](https://github.com/marcelsud/keel/blob/main/LICENSE)
@@ -0,0 +1,92 @@
1
+ import { type NatsConnection } from "@nats-io/transport-node";
2
+ import { ServiceError } from "@nats-io/services";
3
+ import type { DescService } from "@bufbuild/protobuf";
4
+ import { type Contract, type ContractOptions, type Schema, type ServiceClient, type ServiceImplementation } from "./protobuf.js";
5
+ export { ServiceError };
6
+ export type { Contract, ContractOptions, ContractState, Schema, ServiceClient, ServiceImplementation } from "./protobuf.js";
7
+ export declare class ValidationError extends Error {
8
+ }
9
+ export interface Options {
10
+ url?: string;
11
+ name: string;
12
+ version?: string;
13
+ token?: string;
14
+ timeout?: number;
15
+ }
16
+ type Request = <T>(operation: string, input: unknown) => Promise<T>;
17
+ export declare class KV {
18
+ private request;
19
+ private scope;
20
+ constructor(request: Request, scope?: string);
21
+ namespace(namespace: string): KV;
22
+ get<T = unknown>(key: string): Promise<T | undefined>;
23
+ set(key: string, value: unknown): Promise<void>;
24
+ delete(key: string): Promise<boolean>;
25
+ }
26
+ export declare class Collections {
27
+ private request;
28
+ constructor(request: Request);
29
+ insert(collection: string, document: Record<string, unknown>): Promise<string>;
30
+ get<T extends object = Record<string, unknown>>(collection: string, id: string): Promise<(T & {
31
+ id: string;
32
+ }) | null>;
33
+ update(collection: string, id: string, document: Record<string, unknown>): Promise<boolean>;
34
+ delete(collection: string, id: string): Promise<boolean>;
35
+ }
36
+ export interface HandlerContext {
37
+ kv: KV;
38
+ collection: Collections;
39
+ pubsub: {
40
+ publish(topic: string, payload: unknown): Promise<void>;
41
+ };
42
+ queue: {
43
+ enqueue(queue: string, payload: unknown): Promise<void>;
44
+ };
45
+ call<T = unknown>(service: string, action: string, input: unknown): Promise<T>;
46
+ client<S extends DescService>(contract: Contract<S>): ServiceClient<S>;
47
+ log: Pick<Console, "info" | "error">;
48
+ }
49
+ export declare class Keel implements HandlerContext {
50
+ private nc;
51
+ private options;
52
+ readonly kv: KV;
53
+ readonly collection: Collections;
54
+ readonly pubsub: HandlerContext["pubsub"];
55
+ readonly queue: HandlerContext["queue"];
56
+ readonly log: Console;
57
+ private endpoints;
58
+ private bindings;
59
+ private nativeService?;
60
+ private messages;
61
+ private deliveries;
62
+ private active;
63
+ private starting?;
64
+ private stopping?;
65
+ private stopped;
66
+ constructor(nc: NatsConnection, options: Options);
67
+ private declaring;
68
+ action<Input, Result>(definition: {
69
+ name: string;
70
+ args?: {
71
+ parse(value: unknown): Input;
72
+ };
73
+ handler: (ctx: HandlerContext, input: Input) => Result | Promise<Result>;
74
+ }): (input: Input) => Promise<Awaited<Result>>;
75
+ service<S extends DescService>(contract: Contract<S>, implementation: ServiceImplementation<S> | undefined, options?: ContractOptions): void;
76
+ client<S extends DescService>(contract: Contract<S>): ServiceClient<S>;
77
+ fetchContract(service: string): Promise<Schema>;
78
+ subscription<Payload>(topic: string, handler: (ctx: HandlerContext, payload: Payload) => unknown | Promise<unknown>, options: {
79
+ name: string;
80
+ }): void;
81
+ worker<Payload>(queue: string, handler: (ctx: HandlerContext, payload: Payload) => unknown | Promise<unknown>): void;
82
+ private bind;
83
+ call<T = unknown>(service: string, action: string, input: unknown): Promise<T>;
84
+ private request;
85
+ private requestBytes;
86
+ start(): Promise<void>;
87
+ private initialize;
88
+ private handle;
89
+ private consume;
90
+ stop(): Promise<void>;
91
+ }
92
+ export declare function createKeel(options: Options): Promise<Keel>;
package/dist/index.js ADDED
@@ -0,0 +1,292 @@
1
+ import { connect } from "@nats-io/transport-node";
2
+ import { ServiceError, Svcm } from "@nats-io/services";
3
+ import { jetstream } from "@nats-io/jetstream";
4
+ import { protobufClient, schemaSubject, serviceEndpoints, validateSchema, } from "./protobuf.js";
5
+ export { ServiceError };
6
+ const prefix = "keel.v1";
7
+ const identifier = /^[a-zA-Z0-9_-]+$/;
8
+ export class ValidationError extends Error {
9
+ }
10
+ function name(value) {
11
+ if (!identifier.test(value))
12
+ throw new ValidationError("invalid service or action name");
13
+ }
14
+ function nonempty(value) {
15
+ if (typeof value !== "string" || !value.trim())
16
+ throw new ValidationError("name must not be empty");
17
+ }
18
+ function json(value) {
19
+ const result = JSON.stringify(value);
20
+ if (result === undefined)
21
+ throw new ValidationError("value must be JSON serializable");
22
+ return result;
23
+ }
24
+ export class KV {
25
+ request;
26
+ scope;
27
+ constructor(request, scope = "") {
28
+ this.request = request;
29
+ this.scope = scope;
30
+ }
31
+ namespace(namespace) {
32
+ nonempty(namespace);
33
+ return new KV(this.request, namespace);
34
+ }
35
+ async get(key) {
36
+ const result = await this.request("kv_get", { namespace: this.scope, key });
37
+ return result.found ? result.value : undefined;
38
+ }
39
+ async set(key, value) {
40
+ json(value);
41
+ await this.request("kv_set", { namespace: this.scope, key, value });
42
+ }
43
+ delete(key) {
44
+ return this.request("kv_delete", { namespace: this.scope, key });
45
+ }
46
+ }
47
+ export class Collections {
48
+ request;
49
+ constructor(request) {
50
+ this.request = request;
51
+ }
52
+ insert(collection, document) {
53
+ return this.request("collection_insert", { collection, document });
54
+ }
55
+ get(collection, id) {
56
+ return this.request("collection_get", { collection, id });
57
+ }
58
+ update(collection, id, document) {
59
+ return this.request("collection_update", { collection, id, document });
60
+ }
61
+ delete(collection, id) {
62
+ return this.request("collection_delete", { collection, id });
63
+ }
64
+ }
65
+ export class Keel {
66
+ nc;
67
+ options;
68
+ kv;
69
+ collection;
70
+ pubsub;
71
+ queue;
72
+ log = console;
73
+ endpoints = new Map();
74
+ bindings = [];
75
+ nativeService;
76
+ messages = [];
77
+ deliveries = [];
78
+ active = new Set();
79
+ starting;
80
+ stopping;
81
+ stopped = false;
82
+ constructor(nc, options) {
83
+ this.nc = nc;
84
+ this.options = options;
85
+ const request = (operation, input) => this.request(`${prefix}.core.${operation}`, input);
86
+ this.kv = new KV(request);
87
+ this.collection = new Collections(request);
88
+ this.pubsub = { publish: async (topic, payload) => {
89
+ json(payload);
90
+ await request("publish", { topic, payload });
91
+ } };
92
+ this.queue = { enqueue: async (topic, payload) => {
93
+ json(payload);
94
+ await request("enqueue", { topic, payload });
95
+ } };
96
+ }
97
+ declaring() {
98
+ if (this.starting || this.stopped)
99
+ throw new Error("declare actions and consumers before start");
100
+ }
101
+ action(definition) {
102
+ this.declaring();
103
+ name(definition.name);
104
+ if (this.endpoints.has(definition.name))
105
+ throw new Error(`duplicate action: ${definition.name}`);
106
+ const invoke = async (payload) => {
107
+ let input = payload;
108
+ if (definition.args) {
109
+ try {
110
+ input = definition.args.parse(payload);
111
+ }
112
+ catch (error) {
113
+ throw new ValidationError(error instanceof Error ? error.message : "invalid input");
114
+ }
115
+ }
116
+ return definition.handler(this, input);
117
+ };
118
+ this.endpoints.set(definition.name, {
119
+ subject: `${prefix}.actions.${this.options.name}.${definition.name}`,
120
+ handler: async (data) => {
121
+ let input;
122
+ try {
123
+ input = JSON.parse(new TextDecoder().decode(data));
124
+ }
125
+ catch {
126
+ throw new ValidationError("input must be JSON");
127
+ }
128
+ return new TextEncoder().encode(json(await invoke(input)));
129
+ },
130
+ });
131
+ return (input) => this.call(this.options.name, definition.name, input);
132
+ }
133
+ service(contract, implementation, options = {}) {
134
+ this.declaring();
135
+ const routes = serviceEndpoints(contract, implementation, options, this);
136
+ for (const name of routes.keys())
137
+ if (this.endpoints.has(name))
138
+ throw new Error(`duplicate contract: ${contract.service.typeName}`);
139
+ for (const [name, route] of routes)
140
+ this.endpoints.set(name, route);
141
+ }
142
+ client(contract) {
143
+ return protobufClient(contract, (subject, data) => this.requestBytes(subject, data));
144
+ }
145
+ async fetchContract(service) {
146
+ return validateSchema(await this.request(schemaSubject(service), null), service);
147
+ }
148
+ subscription(topic, handler, options) {
149
+ this.bind({ kind: "subscription", topic, name: options?.name, handler: handler });
150
+ }
151
+ worker(queue, handler) {
152
+ this.bind({ kind: "worker", topic: queue, name: queue, handler: handler });
153
+ }
154
+ bind(binding) {
155
+ this.declaring();
156
+ nonempty(binding.topic);
157
+ nonempty(binding.name);
158
+ if (this.bindings.some((b) => b.kind === binding.kind && b.topic === binding.topic && b.name === binding.name)) {
159
+ throw new Error("duplicate consumer");
160
+ }
161
+ this.bindings.push(binding);
162
+ }
163
+ call(service, action, input) {
164
+ name(service);
165
+ name(action);
166
+ return this.request(`${prefix}.actions.${service}.${action}`, input);
167
+ }
168
+ async request(subject, input) {
169
+ const data = await this.requestBytes(subject, new TextEncoder().encode(json(input)));
170
+ return JSON.parse(new TextDecoder().decode(data));
171
+ }
172
+ async requestBytes(subject, data) {
173
+ if (this.nc.isClosed())
174
+ throw new Error("app is stopped");
175
+ const message = await this.nc.request(subject, data, { timeout: this.options.timeout ?? 5000 });
176
+ const error = ServiceError.toServiceError(message);
177
+ if (error)
178
+ throw error;
179
+ return message.data;
180
+ }
181
+ start() {
182
+ if (this.stopped)
183
+ return Promise.reject(new Error("app is stopped"));
184
+ this.starting ??= this.initialize().catch((error) => { this.starting = undefined; throw error; });
185
+ return this.starting;
186
+ }
187
+ async initialize() {
188
+ try {
189
+ this.nativeService = await new Svcm(this.nc).add({ name: this.options.name, version: this.options.version ?? "0.1.0" });
190
+ for (const [name, route] of this.endpoints) {
191
+ this.nativeService.addEndpoint(name, {
192
+ subject: route.subject, metadata: route.metadata,
193
+ handler: (error, message) => {
194
+ if (error) {
195
+ this.log.error("action subscription failed", error);
196
+ return;
197
+ }
198
+ if (this.stopped) {
199
+ message.respondError(503, "app is stopping");
200
+ return;
201
+ }
202
+ const task = this.handle(message, route.handler);
203
+ this.active.add(task);
204
+ void task.finally(() => this.active.delete(task));
205
+ },
206
+ });
207
+ }
208
+ const js = jetstream(this.nc);
209
+ for (const binding of this.bindings) {
210
+ const info = await this.request(`${prefix}.core.consumer`, {
211
+ kind: binding.kind, topic: binding.topic, name: binding.name,
212
+ });
213
+ const consumer = await js.consumers.get(info.stream, info.consumer);
214
+ this.messages.push(await consumer.consume({ max_messages: 1, expires: 1000 }));
215
+ }
216
+ await this.nc.flush();
217
+ this.deliveries = this.messages.map((messages, index) => this.consume(messages, this.bindings[index].handler));
218
+ }
219
+ catch (error) {
220
+ await Promise.all(this.messages.map((messages) => messages.close()));
221
+ this.messages = [];
222
+ await this.nativeService?.stop();
223
+ this.nativeService = undefined;
224
+ throw error;
225
+ }
226
+ }
227
+ async handle(message, handler) {
228
+ try {
229
+ message.respond(await handler(message.data));
230
+ }
231
+ catch (error) {
232
+ if (error instanceof ValidationError)
233
+ message.respondError(400, error.message);
234
+ else if (error instanceof ServiceError)
235
+ message.respondError(error.code, error.message);
236
+ else {
237
+ this.log.error("action failed", error);
238
+ message.respondError(500, "handler failed");
239
+ }
240
+ }
241
+ }
242
+ async consume(messages, handler) {
243
+ try {
244
+ for await (const message of messages) {
245
+ if (this.stopped) {
246
+ message.nak();
247
+ continue;
248
+ }
249
+ const heartbeat = setInterval(() => message.working(), 10_000);
250
+ try {
251
+ await handler(this, message.json());
252
+ await message.ackAck();
253
+ }
254
+ catch (error) {
255
+ this.log.error("delivery failed", error);
256
+ message.nak(1000);
257
+ }
258
+ finally {
259
+ clearInterval(heartbeat);
260
+ }
261
+ }
262
+ }
263
+ catch (error) {
264
+ if (!this.stopped)
265
+ this.log.error("consumer stopped", error);
266
+ }
267
+ }
268
+ stop() {
269
+ this.stopped = true;
270
+ this.stopping ??= (async () => {
271
+ try {
272
+ await this.starting?.catch(() => { });
273
+ await this.nativeService?.stop();
274
+ await Promise.all(this.messages.map((messages) => messages.close()));
275
+ await Promise.all(this.deliveries);
276
+ await Promise.all(this.active);
277
+ await this.nc.drain();
278
+ }
279
+ finally {
280
+ await this.nc.close();
281
+ }
282
+ })();
283
+ return this.stopping;
284
+ }
285
+ }
286
+ export async function createKeel(options) {
287
+ name(options.name);
288
+ if (options.name === "keel-core")
289
+ throw new ValidationError("reserved service name");
290
+ const nc = await connect({ servers: options.url ?? "nats://127.0.0.1:4222", token: options.token, name: options.name, timeout: 5000 });
291
+ return new Keel(nc, options);
292
+ }
@@ -0,0 +1,33 @@
1
+ import { type DescService, type MessageInitShape, type MessageShape } from "@bufbuild/protobuf";
2
+ import type { HandlerContext } from "./index.js";
3
+ export type ContractState = "active" | "deprecated" | "retired";
4
+ export interface Contract<S extends DescService = DescService> {
5
+ service: S;
6
+ schema: string;
7
+ }
8
+ export interface ContractOptions {
9
+ state?: ContractState;
10
+ replacement?: string;
11
+ }
12
+ export interface Schema {
13
+ service: string;
14
+ sha256: string;
15
+ state: ContractState;
16
+ replacement?: string;
17
+ descriptorSet: string;
18
+ }
19
+ export type ServiceImplementation<S extends DescService> = {
20
+ [K in keyof S["method"]]: S["method"][K]["methodKind"] extends "unary" ? (ctx: HandlerContext, input: MessageShape<S["method"][K]["input"]>) => MessageInitShape<S["method"][K]["output"]> | Promise<MessageInitShape<S["method"][K]["output"]>> : never;
21
+ };
22
+ export type ServiceClient<S extends DescService> = {
23
+ [K in keyof S["method"]]: S["method"][K]["methodKind"] extends "unary" ? (input: MessageInitShape<S["method"][K]["input"]>) => Promise<MessageShape<S["method"][K]["output"]>> : never;
24
+ };
25
+ export interface BinaryEndpoint {
26
+ subject: string;
27
+ handler(data: Uint8Array): Promise<Uint8Array>;
28
+ metadata?: Record<string, string>;
29
+ }
30
+ export declare function schemaSubject(service: string): string;
31
+ export declare function serviceEndpoints<S extends DescService>(contract: Contract<S>, implementation: ServiceImplementation<S> | undefined, options: ContractOptions, context: HandlerContext): Map<string, BinaryEndpoint>;
32
+ export declare function protobufClient<S extends DescService>(contract: Contract<S>, request: (subject: string, data: Uint8Array) => Promise<Uint8Array>): ServiceClient<S>;
33
+ export declare function validateSchema(value: Schema, service: string): Promise<Schema>;
@@ -0,0 +1,104 @@
1
+ import { create, createFileRegistry, fromBinary, toBinary, } from "@bufbuild/protobuf";
2
+ import { FileDescriptorSetSchema } from "@bufbuild/protobuf/wkt";
3
+ import { ServiceError } from "@nats-io/services";
4
+ const prefix = "keel.v1";
5
+ const encoder = new TextEncoder();
6
+ const qualifiedName = /^[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)+$/;
7
+ function decode(value) {
8
+ return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
9
+ }
10
+ async function digest(data) {
11
+ const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new Uint8Array(data)));
12
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
13
+ }
14
+ export function schemaSubject(service) {
15
+ if (!qualifiedName.test(service))
16
+ throw new Error("invalid contract name");
17
+ return `${prefix}.schema.${service}`;
18
+ }
19
+ function rpcSubject(service, method) { return `${prefix}.rpc.${service}.${method}`; }
20
+ function endpointName(kind, subject) {
21
+ return `${kind}_${btoa(subject).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "")}`;
22
+ }
23
+ function descriptor(data, service) {
24
+ const registry = createFileRegistry(fromBinary(FileDescriptorSetSchema, data));
25
+ const result = registry.getService(service);
26
+ if (!result)
27
+ throw new Error(`contract does not contain service ${service}`);
28
+ if (result.methods.some((method) => method.methodKind !== "unary"))
29
+ throw new Error("streaming RPCs are not supported");
30
+ return result;
31
+ }
32
+ export function serviceEndpoints(contract, implementation, options, context) {
33
+ const service = contract.service;
34
+ schemaSubject(service.typeName);
35
+ const data = decode(contract.schema);
36
+ const embedded = descriptor(data, service.typeName);
37
+ const state = options.state ?? (service.proto.options?.deprecated ? "deprecated" : "active");
38
+ const replacement = options.replacement;
39
+ if (!["active", "deprecated", "retired"].includes(state))
40
+ throw new Error("invalid contract state");
41
+ if (replacement !== undefined && !qualifiedName.test(replacement))
42
+ throw new Error("replacement must be a qualified contract name");
43
+ if (embedded.methods.length !== service.methods.length)
44
+ throw new Error("contract method mismatch");
45
+ const metadata = { contract: service.typeName, state, replacement: replacement ?? "" };
46
+ const routes = new Map();
47
+ routes.set(endpointName("schema", service.typeName), {
48
+ subject: schemaSubject(service.typeName), metadata,
49
+ handler: async () => encoder.encode(JSON.stringify({
50
+ service: service.typeName, sha256: await digest(data), state, replacement, descriptorSet: contract.schema,
51
+ })),
52
+ });
53
+ for (const method of service.methods) {
54
+ const declared = embedded.methods.find((candidate) => candidate.name === method.name);
55
+ if (!declared || declared.input.typeName !== method.input.typeName || declared.output.typeName !== method.output.typeName || method.methodKind !== "unary") {
56
+ throw new Error("contract method mismatch");
57
+ }
58
+ const handler = implementation?.[method.localName];
59
+ if (state !== "retired" && typeof handler !== "function")
60
+ throw new Error(`missing handler for ${method.name}`);
61
+ routes.set(endpointName("rpc", `${service.typeName}.${method.name}`), {
62
+ subject: rpcSubject(service.typeName, method.name), metadata,
63
+ handler: async (bytes) => {
64
+ if (state === "retired")
65
+ throw new ServiceError(410, `API retired; replacement: ${replacement ?? ""}`);
66
+ let input;
67
+ try {
68
+ input = fromBinary(method.input, bytes);
69
+ }
70
+ catch {
71
+ throw new ServiceError(400, "invalid protobuf request");
72
+ }
73
+ // Reflection erases method-specific types here; the public mapped type checks both sides.
74
+ const invoke = handler;
75
+ return toBinary(method.output, create(method.output, await invoke(context, input)));
76
+ },
77
+ });
78
+ }
79
+ return routes;
80
+ }
81
+ export function protobufClient(contract, request) {
82
+ schemaSubject(contract.service.typeName);
83
+ const result = Object.create(null);
84
+ for (const method of contract.service.methods) {
85
+ if (method.methodKind !== "unary")
86
+ throw new Error("streaming RPCs are not supported");
87
+ result[method.localName] = async (input) => {
88
+ const data = toBinary(method.input, create(method.input, input));
89
+ return fromBinary(method.output, await request(rpcSubject(contract.service.typeName, method.name), data));
90
+ };
91
+ }
92
+ return result;
93
+ }
94
+ export async function validateSchema(value, service) {
95
+ if (value.service !== service || !["active", "deprecated", "retired"].includes(value.state))
96
+ throw new Error("invalid contract metadata");
97
+ if (value.replacement !== undefined && !qualifiedName.test(value.replacement))
98
+ throw new Error("invalid replacement contract");
99
+ const data = decode(value.descriptorSet);
100
+ if (await digest(data) !== value.sha256)
101
+ throw new Error("contract checksum mismatch");
102
+ descriptor(data, service);
103
+ return value;
104
+ }
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@marcelsud/keel.ts",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "TypeScript SDK for Keel services, typed clients, storage, events, and background jobs.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
10
+ "files": ["dist/*.js", "dist/*.d.ts", "README.md", "LICENSE"],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/marcelsud/keel.git",
14
+ "directory": "sdks/typescript"
15
+ },
16
+ "homepage": "https://github.com/marcelsud/keel#readme",
17
+ "bugs": { "url": "https://github.com/marcelsud/keel/issues" },
18
+ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
19
+ "scripts": {
20
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json",
21
+ "typecheck": "tsc -p test/tsconfig.json",
22
+ "example:service": "npm run build && node ../../examples/contracts/typescript.ts",
23
+ "test": "npm run build && npm run typecheck && node --test test/*.test.mjs",
24
+ "prepack": "npm run build",
25
+ "prepublishOnly": "npm test"
26
+ },
27
+ "engines": { "node": ">=22" },
28
+ "dependencies": {
29
+ "@bufbuild/protobuf": "2.16.0",
30
+ "@nats-io/transport-node": "3.4.0",
31
+ "@nats-io/services": "3.4.0",
32
+ "@nats-io/jetstream": "3.4.0"
33
+ },
34
+ "devDependencies": { "@bufbuild/protoc-gen-es": "2.16.0", "typescript": "5.9.3" }
35
+ }