StillDeep SDK — TypeScript / Node.js client

TypeScript and Node.js client: code generation, shift, first sale, event stream.

Prerequisites

  • Node.js 22 LTS+
  • protoc 25+
  • ts-proto plugin (npm i -D ts-proto)

Setup

mkdir my-pos-client && cd my-pos-client
npm init -y
npm i @grpc/grpc-js @grpc/proto-loader
npm i -D typescript ts-proto @types/node
cp -r ../waxpos/src/public/StillDeep.Intelligence.Contracts/protos ./protos

mkdir -p src/generated

protoc \
  --plugin=./node_modules/.bin/protoc-gen-ts_proto \
  --ts_proto_out=./src/generated \
  --ts_proto_opt=outputServices=grpc-js,esModuleInterop=true \
  --proto_path=./protos \
  protos/common.proto protos/receipt.proto protos/sales.proto protos/catalog.proto \
  protos/member.proto protos/device.proto protos/events.proto \
  protos/user.proto protos/shift.proto

ts-proto maps fields to camelCase (cart_id -> cartId), int64 to number, and generates fromPartial() to build requests.

Example: sign in, open the shift, sell one item

Sequence and required fields: see getting-started.md. Run as an ES module (top-level await).

import { credentials, Metadata, ServiceError, status } from '@grpc/grpc-js';
import { randomUUID } from 'node:crypto';
import { AuthMode, AuthenticateRequest, AuthenticateResponse, UserServiceClient } from './generated/user';
import { GetCurrentShiftRequest, OpenShiftRequest, OpenShiftResponse, Shift, ShiftServiceClient } from './generated/shift';
import {
  AddItemRequest, BeginPaymentRequest, CartResponse, CreateCartRequest, FinalizeRequest,
  PaymentResponse, PaymentStatus, ProcessPaymentRequest, SalesServiceClient, TransactionResponse,
} from './generated/sales';

const address = process.env.STILLDEEP_GRPC ?? 'localhost:50051';
const postId = process.env.STILLDEEP_POST_ID!;          // required: id of this post
const sku = process.env.STILLDEEP_SKU!;                 // product to sell
const methodCode = process.env.STILLDEEP_METHOD ?? 'cash'; // a code from ConfigService.GetPostConfig

const creds = credentials.createInsecure();
const users = new UserServiceClient(address, creds);
const shifts = new ShiftServiceClient(address, creds);
const sales = new SalesServiceClient(address, creds);

const unary = <T>(fn: (cb: (err: ServiceError | null, res: T) => void) => void) =>
  new Promise<T>((resolve, reject) => fn((err, res) => (err ? reject(err) : resolve(res))));

// 1. Authenticate (required).
const auth = await unary<AuthenticateResponse>((cb) => users.authenticate(AuthenticateRequest.fromPartial({
  postId, mode: AuthMode.AUTH_MODE_PASSWORD, login: process.env.STILLDEEP_LOGIN!, password: process.env.STILLDEEP_PASSWORD!,
}), cb));
const userId = auth.user!.userId;

// 2-3. Reuse the open shift, or open one.
let shift: Shift;
try {
  shift = await unary<Shift>((cb) => shifts.getCurrentShift(GetCurrentShiftRequest.fromPartial({ postId }), cb));
} catch (e) {
  if ((e as ServiceError).code !== status.NOT_FOUND) throw e;
  const opened = await unary<OpenShiftResponse>((cb) => shifts.openShift(OpenShiftRequest.fromPartial({
    postId, userId, openingCash: { cents: 0 }, idempotencyKey: randomUUID(),
  }), cb));
  shift = opened.shift!;
}

// Every following call carries x-shift-id.
const md = new Metadata();
md.set('x-shift-id', shift.shiftId);

// 4. CreateCart (required).
const created = await unary<CartResponse>((cb) => sales.createCart(CreateCartRequest.fromPartial({
  postId, idempotencyKey: randomUUID(),
}), md, cb));
const cartId = created.cart!.cartId;

// 5. AddItem (required): product_id, sku or barcode; quantity in milli-units.
await unary<CartResponse>((cb) => sales.addItem(AddItemRequest.fromPartial({
  cartId, sku, quantity: { milliUnits: 1000 }, idempotencyKey: randomUUID(),
}), md, cb));

// 6. BeginPayment (optional): handle an extension question before paying.
const checkout = await unary<CartResponse>((cb) => sales.beginPayment(BeginPaymentRequest.fromPartial({ cartId }), md, cb));
if (checkout.question) throw new Error(`Answer required: ${checkout.question.code}`);

// 8. ProcessPayment (required): pay the remaining amount.
const paid = await unary<PaymentResponse>((cb) => sales.processPayment(ProcessPaymentRequest.fromPartial({
  cartId, methodCode, amount: checkout.cart!.remainingDue, idempotencyKey: randomUUID(),
}), md, cb));
if (paid.payment!.status === PaymentStatus.PAYMENT_STATUS_DECLINED
  || paid.payment!.status === PaymentStatus.PAYMENT_STATUS_FAILED) {
  throw new Error(paid.refusalGuide?.userFacingMessage ?? 'payment refused');
}

// 9. FinalizeTransaction (required).
const done = await unary<TransactionResponse>((cb) => sales.finalizeTransaction(FinalizeRequest.fromPartial({
  cartId, idempotencyKey: randomUUID(),
}), md, cb));
console.log('Receipt', done.transaction!.receiptNumber, 'change', paid.changeDue?.cents);

Event stream

EventService is declared in events.proto but not served by the current POSIntelligenceSvc (UNIMPLEMENTED).

import { EventServiceClient, SubscribeRequest } from './generated/events';

const events = new EventServiceClient(address, creds);
const stream = events.subscribe(
  SubscribeRequest.fromPartial({ subscriberId: randomUUID(), kinds: [], postIdFilter: postId }), md);
stream.on('data', (ev) => console.log('event', ev));
stream.on('error', (e) => console.error(e));

Known V1 limitations

  • No TLS by default (local insecure channel). Over a network, add a certificate.
  • No automatic client retry policy: the consumer implements it.
  • EventService.Heartbeat (bidi streaming) is declared; prefer Subscribe for most cases.