This is an automated email from the ASF dual-hosted git repository.

RongtongJin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/rocketmq-clients.git


The following commit(s) were added to refs/heads/master by this push:
     new cf0893a9 feat(nodejs): implement LiteSimpleConsumer (#1379)
cf0893a9 is described below

commit cf0893a9259c704ecb6b70a94691735aa977dd0b
Author: zhaohai <[email protected]>
AuthorDate: Thu Sep 17 14:46:21 2026 +0800

    feat(nodejs): implement LiteSimpleConsumer (#1379)
    
    * feat(nodejs): implement LiteSimpleConsumer
    
    Port the lite simple consumer from the reference client implementation:
    
    - Add `LiteSimpleConsumer` (interface + builder options) and
      `LiteSimpleConsumerImpl` with `subscribeLite` / `unsubscribeLite`,
      `receive`, `ack`, `changeInvisibleDuration` and startup/shutdown flow
    - Rework `LiteSubscriptionManager` to be host-agnostic and sync lite
      subscriptions (FULL / PARTIAL_ADD / PARTIAL_REMOVE) to every sync
      endpoint, with quota handling and notify-unsubscribe support
    - Handle `NOTIFY_UNSUBSCRIBE_LITE_COMMAND` in `BaseClient` /
      `TelemetrySession` and align the callback signature across consumers
    - Fix lite ack path: include `liteTopic` in ack and
      change-invisible-duration requests for lite consumers, matching the
      server-side receipt handle resolution
    - Add offline unit tests (builder validation, subscription sync,
      route pruning) and real-cluster integration tests (end-to-end
      send/receive/ack, subscribe-then-send delivery, unsubscribe-stop,
      blank topic validation)
    
    * docs(nodejs): add LiteSimpleConsumer example
    
    Add a runnable LiteSimpleConsumer example and document the lite topic
    usage in the Node.js README:
    
    - examples/LiteSimpleConsumerExample.ts: build a lite simple consumer
      bound to a parent topic, subscribe/unsubscribe lite topics with
      OffsetOption, receive-ack loop with changeInvisibleDuration fallback
      on ack failure, and graceful shutdown on SIGINT
    - README: new "Lite Topic" section covering the server prerequisites,
      producer-side liteTopic send, and the LiteSimpleConsumer pull/ack flow
    
    * refactor(nodejs): stop exporting LiteSimpleConsumerImpl from the consumer 
barrel
    
    The consumer barrel re-exported LiteSimpleConsumerImpl, so its host-only
    helpers (getRpcClientManager, getLogger, getRequestTimeout, 
getSyncEndpoints)
    ended up in the published .d.ts and became public API - hard to change once 
the
    package is released.
    
    Export the interface + builder only (`export * from 
'./LiteSimpleConsumer'`).
    Users still obtain instances through `build()`, which returns the
    `LiteSimpleConsumer` interface. Tests import the impl by file path, so they 
are
    unaffected. LitePushConsumerImpl is intentionally left exported.
    
    Verified:
    - emitted declarations: the entry chain (index.d.ts -> consumer/index.d.ts) 
no
      longer references LiteSimpleConsumerImpl, while
      `build(): Promise<LiteSimpleConsumer>` is unchanged;
    - tsc -p tsconfig.prod.json and -p tsconfig.test.json: 0 errors;
    - eslint: 0 errors;
    - non-integration suite: 97/97 pass.
---
 nodejs/README.md                                   |  61 ++++
 nodejs/examples/LiteSimpleConsumerExample.ts       | 128 +++++++
 nodejs/src/client/BaseClient.ts                    |   6 +
 nodejs/src/client/TelemetrySession.ts              |   6 +
 nodejs/src/consumer/Consumer.ts                    |  12 +-
 nodejs/src/consumer/LitePushConsumerImpl.ts        |  12 +-
 nodejs/src/consumer/LiteSimpleConsumer.ts          | 251 ++++++++++++++
 nodejs/src/consumer/LiteSimpleConsumerImpl.ts      | 221 ++++++++++++
 nodejs/src/consumer/LiteSubscriptionManager.ts     |  99 ++++--
 nodejs/src/consumer/index.ts                       |   1 +
 .../LiteSimpleConsumer.integration.test.ts         | 201 +++++++++++
 nodejs/test/consumer/LiteSimpleConsumer.test.ts    | 374 +++++++++++++++++++++
 12 files changed, 1332 insertions(+), 40 deletions(-)

diff --git a/nodejs/README.md b/nodejs/README.md
index e36a0417..9a4d051c 100644
--- a/nodejs/README.md
+++ b/nodejs/README.md
@@ -169,6 +169,67 @@ try {
 }
 ```
 
+### Lite Topic
+
+Lite topics are light-weight subscriptions on top of one parent topic. A lite 
consumer binds to the
+parent topic and (un)subscribes lite topics at runtime, which makes it cheap 
to serve a large number
+of short-lived topics.
+
+Server prerequisites: broker with `enableLmq=true` and 
`enableMultiDispatch=true`, a parent topic
+created with `message.type=LITE`, and a consumer group created with the 
attribute
+`+lite.bind.topic=<parentTopic>`.
+
+Send with lite topic
+
+```ts
+import { Producer } from 'rocketmq-client-nodejs';
+
+const producer = new Producer({
+  endpoints: '127.0.0.1:8081',
+});
+await producer.startup();
+
+await producer.send({
+  topic: 'yourParentTopic',
+  liteTopic: 'lite-topic-1', // The lite topic the message belongs to
+  body: Buffer.from('This is a lite message'),
+});
+
+await producer.shutdown();
+```
+
+LiteSimpleConsumer (pull and ack explicitly)
+
+```ts
+import { LiteSimpleConsumerBuilder, OffsetOption } from 
'rocketmq-client-nodejs';
+
+// Bind to the parent topic, the consumer group must be bound to it as well
+const consumer = await new LiteSimpleConsumerBuilder()
+  .setClientConfiguration({ endpoints: '127.0.0.1:8081' })
+  .setConsumerGroup('yourConsumerGroup')
+  .bindTopic('yourParentTopic')
+  .setAwaitDuration(5000)
+  .build();
+
+// Subscribe lite topics, with or without a consume-from offset
+await consumer.subscribeLite('lite-topic-1', OffsetOption.MIN_OFFSET);
+await consumer.subscribeLite('lite-topic-2');
+
+// Pull a batch and ack every message
+const messages = await consumer.receive(16, 15000);
+for (const message of messages) {
+  console.log(message.liteTopic, message.body.toString());
+  await consumer.ack(message);
+}
+
+// Release the subscription when it's no longer needed
+await consumer.unsubscribeLite('lite-topic-2');
+await consumer.close();
+```
+
+A runnable version lives in `examples/LiteSimpleConsumerExample.ts` (see also
+`examples/LiteProducerExample.ts` and `examples/LitePushConsumerExample.ts`).
+
 ## Current Progress
 
 ### Message Type
diff --git a/nodejs/examples/LiteSimpleConsumerExample.ts 
b/nodejs/examples/LiteSimpleConsumerExample.ts
new file mode 100644
index 00000000..b7e44880
--- /dev/null
+++ b/nodejs/examples/LiteSimpleConsumerExample.ts
@@ -0,0 +1,128 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * LiteSimpleConsumer Example
+ *
+ * This example demonstrates how to use LiteSimpleConsumer to pull messages 
from
+ * lite topics with explicit receive / ack control.
+ *
+ * Prerequisites:
+ * - RocketMQ proxy plus a broker with lite support enabled
+ *   (broker.conf: enableLmq=true, enableMultiDispatch=true)
+ * - A parent topic created with message.type=LITE
+ * - A consumer group created with the attribute +lite.bind.topic=<parentTopic>
+ * - Messages published to the parent topic with the liteTopic property set,
+ *   see LiteProducerExample.ts
+ */
+
+import { LiteSimpleConsumerBuilder, OffsetOption, type MessageView } from 
'../src';
+import { endpoints, namespace, consumerGroup, sessionCredentials, 
liteTopicConfig } from './ProducerSingleton';
+
+const BIND_TOPIC = liteTopicConfig.parentTopic;
+const INVISIBLE_DURATION = 15000; // 15s, the visibility timeout of received 
messages
+
+async function main() {
+  console.log('========== LiteSimpleConsumer Example ==========\n');
+
+  // Build and start the lite simple consumer, it binds to one parent topic 
only.
+  const consumer = await new LiteSimpleConsumerBuilder()
+    .setClientConfiguration({
+      endpoints,
+      namespace,
+      sessionCredentials,
+    })
+    .setConsumerGroup(consumerGroup)
+    .bindTopic(BIND_TOPIC) // Replace with your actual parent topic
+    .setAwaitDuration(5000) // Long-polling timeout of receive()
+    .build();
+
+  console.log(`✓ Consumer started, bindTopic=${BIND_TOPIC}, 
group=${consumerGroup}\n`);
+
+  let running = true;
+  process.on('SIGINT', () => {
+    console.log('\nReceived SIGINT, stopping...');
+    running = false;
+  });
+
+  try {
+    // Subscribe to lite topics. All of them must belong to the bound parent 
topic.
+    console.log('Subscribing to lite topics...\n');
+
+    await consumer.subscribeLite('lite-topic-1', OffsetOption.MIN_OFFSET);
+    console.log('✓ Subscribed to lite-topic-1 (from the minimum offset)');
+
+    await consumer.subscribeLite('lite-topic-2');
+    console.log('✓ Subscribed to lite-topic-2 (from the latest offset)');
+
+    await consumer.subscribeLite('lite-topic-3', OffsetOption.ofTailN(100));
+    console.log('✓ Subscribed to lite-topic-3 (the last 100 messages)\n');
+
+    console.log('Current lite topic set:', [ ...consumer.getLiteTopicSet() ], 
'\n');
+    console.log('Receiving messages. Press Ctrl+C to exit...\n');
+
+    // Pull messages by batch, then ack them one by one.
+    while (running) {
+      const messages: MessageView[] = await consumer.receive(16, 
INVISIBLE_DURATION);
+      if (messages.length === 0) {
+        continue;
+      }
+
+      for (const message of messages) {
+        console.log('Received message:', {
+          messageId: message.messageId,
+          topic: message.topic,
+          liteTopic: message.liteTopic,
+          tag: message.tag,
+          keys: message.keys,
+          body: message.body.toString('utf-8'),
+        });
+
+        try {
+          // Ack as soon as the business logic succeeds, otherwise the message
+          // becomes visible again after the invisible duration expires.
+          await consumer.ack(message);
+          console.log(`✓ Acked message ${message.messageId}\n`);
+        } catch (error) {
+          console.error(`✗ Failed to ack message ${message.messageId}:`, 
error);
+
+          // Not ready to process it yet? Extend the invisible duration instead
+          // of dropping the message.
+          await consumer.changeInvisibleDuration(message, INVISIBLE_DURATION);
+        }
+      }
+    }
+
+    // Release the subscriptions that are no longer needed, this frees the lite
+    // topic quota of the consumer group.
+    console.log('\nUnsubscribing from lite-topic-3...');
+    await consumer.unsubscribeLite('lite-topic-3');
+    console.log('✓ Unsubscribed, remaining lite topic set:', [ 
...consumer.getLiteTopicSet() ], '\n');
+  } catch (error) {
+    console.error('Error during consumption:', error);
+    throw error;
+  } finally {
+    console.log('Shutting down consumer...');
+    await consumer.close();
+    console.log('✓ Consumer closed successfully');
+  }
+}
+
+main().catch(error => {
+  console.error('Fatal error:', error);
+  process.exit(1);
+});
diff --git a/nodejs/src/client/BaseClient.ts b/nodejs/src/client/BaseClient.ts
index 7bd22802..acf694d5 100644
--- a/nodejs/src/client/BaseClient.ts
+++ b/nodejs/src/client/BaseClient.ts
@@ -30,6 +30,7 @@ import {
   VerifyMessageCommand,
   PrintThreadStackTraceCommand,
   ReconnectEndpointsCommand,
+  NotifyUnsubscribeLiteCommand,
   TelemetryCommand,
   ThreadStackTrace,
   HeartbeatRequest,
@@ -518,6 +519,11 @@ export abstract class BaseClient {
     this.telemetry(endpoints, telemetryCommand);
   }
 
+  onNotifyUnsubscribeLiteCommand(endpoints: Endpoints, command: 
NotifyUnsubscribeLiteCommand) {
+    this.logger.warn('Ignore notify unsubscribe lite command from remote, 
which is not expected, clientId=%s, endpoints=%s, command=%j',
+      this.clientId, endpoints.facade, command.toObject());
+  }
+
   onPrintThreadStackTraceCommand(endpoints: Endpoints, command: 
PrintThreadStackTraceCommand) {
     const obj = command.toObject();
     this.logger.warn('Ignore orphaned transaction recovery command from 
remote, which is not expected, clientId=%s, command=%j',
diff --git a/nodejs/src/client/TelemetrySession.ts 
b/nodejs/src/client/TelemetrySession.ts
index d2abdef4..d0fcf231 100644
--- a/nodejs/src/client/TelemetrySession.ts
+++ b/nodejs/src/client/TelemetrySession.ts
@@ -134,6 +134,12 @@ export class TelemetrySession {
         this.#baseClient.onReconnectEndpointsCommand(endpoints, 
command.getReconnectEndpointsCommand()!);
         break;
       }
+      case TelemetryCommand.CommandCase.NOTIFY_UNSUBSCRIBE_LITE_COMMAND: {
+        this.#logger.info('Receive notify unsubscribe lite command from 
remote, endpoints=%s, clientId=%s',
+          endpoints, clientId);
+        this.#baseClient.onNotifyUnsubscribeLiteCommand(endpoints, 
command.getNotifyUnsubscribeLiteCommand()!);
+        break;
+      }
       default: {
         const commandObj = command.toObject();
         this.#logger.warn('Receive unrecognized command from remote, 
endpoints=%s, commandCase=%j, command=%j, clientId=%s',
diff --git a/nodejs/src/consumer/Consumer.ts b/nodejs/src/consumer/Consumer.ts
index ecbfbbb0..23a4330a 100644
--- a/nodejs/src/consumer/Consumer.ts
+++ b/nodejs/src/consumer/Consumer.ts
@@ -104,9 +104,12 @@ export abstract class Consumer extends BaseClient {
     const request = new AckMessageRequest()
       .setGroup(createResource(this.consumerGroup))
       .setTopic(createResource(messageView.topic));
-    request.addEntries()
+    const entry = request.addEntries()
       .setMessageId(messageView.messageId)
       .setReceiptHandle(messageView.receiptHandle);
+    if (this.isLiteConsumer() && messageView.liteTopic) {
+      entry.setLiteTopic(messageView.liteTopic);
+    }
     const res = await this.rpcClientManager.ackMessage(endpoints, request, 
this.requestTimeout);
     // FIXME: handle fail ack
     const response = res.toObject();
@@ -121,6 +124,9 @@ export abstract class Consumer extends BaseClient {
       .setReceiptHandle(messageView.receiptHandle)
       .setInvisibleDuration(createDuration(invisibleDuration))
       .setMessageId(messageView.messageId);
+    if (this.isLiteConsumer() && messageView.liteTopic) {
+      request.setLiteTopic(messageView.liteTopic);
+    }
 
     const res = await 
this.rpcClientManager.changeInvisibleDuration(messageView.endpoints, request, 
this.requestTimeout);
     const response = res.toObject();
@@ -158,10 +164,10 @@ export abstract class Consumer extends BaseClient {
   /**
    * Check if this is a lite consumer.
    *
-   * @return true if this is a LITE_PUSH_CONSUMER
+   * @return true if this is a LITE_PUSH_CONSUMER or LITE_SIMPLE_CONSUMER
    */
   protected isLiteConsumer(): boolean {
     const clientType = (this as any).getClientType?.();
-    return clientType === ClientType.LITE_PUSH_CONSUMER;
+    return clientType === ClientType.LITE_PUSH_CONSUMER || clientType === 
ClientType.LITE_SIMPLE_CONSUMER;
   }
 }
diff --git a/nodejs/src/consumer/LitePushConsumerImpl.ts 
b/nodejs/src/consumer/LitePushConsumerImpl.ts
index 37440b51..158a883d 100644
--- a/nodejs/src/consumer/LitePushConsumerImpl.ts
+++ b/nodejs/src/consumer/LitePushConsumerImpl.ts
@@ -218,9 +218,10 @@ export class LitePushConsumerImpl extends PushConsumer 
implements LitePushConsum
    * <p>This method is called when the server sends a notification to 
unsubscribe
    * from a lite topic, typically due to quota violations or administrative 
actions.</p>
    *
+   * @param _endpoints - The server endpoints
    * @param command - The unsubscribe command from the server
    */
-  onNotifyUnsubscribeLiteCommand(command: NotifyUnsubscribeLiteCommand) {
+  onNotifyUnsubscribeLiteCommand(_endpoints: Endpoints, command: 
NotifyUnsubscribeLiteCommand) {
     this.liteSubscriptionManager.onNotifyUnsubscribeLiteCommand(command);
   }
 
@@ -278,4 +279,13 @@ export class LitePushConsumerImpl extends PushConsumer 
implements LitePushConsum
   getRequestTimeout(): number {
     return this.requestTimeout;
   }
+
+  /**
+   * Endpoints the lite subscription manager should sync to.
+   *
+   * @internal
+   */
+  getSyncEndpoints(): Endpoints[] {
+    return [ this.endpoints ];
+  }
 }
diff --git a/nodejs/src/consumer/LiteSimpleConsumer.ts 
b/nodejs/src/consumer/LiteSimpleConsumer.ts
new file mode 100644
index 00000000..cd0f9ff5
--- /dev/null
+++ b/nodejs/src/consumer/LiteSimpleConsumer.ts
@@ -0,0 +1,251 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { BaseClientOptions } from '../client';
+import { MessageView } from '../message';
+import { OffsetOption } from './OffsetOption';
+import { LiteSimpleConsumerImpl } from './LiteSimpleConsumerImpl';
+
+/**
+ * LiteSimpleConsumer interface for consuming messages from lite topics with
+ * explicit receive/ack control.
+ *
+ * <p>Similar to SimpleConsumer, but bound to a single parent topic and able to
+ * (un)subscribe lite topics dynamically through the lite subscription sync
+ * protocol.</p>
+ */
+export interface LiteSimpleConsumer {
+  /**
+   * Get the load balancing group for the lite simple consumer.
+   *
+   * @return Consumer load balancing group
+   */
+  getConsumerGroup(): string;
+
+  /**
+   * Subscribe to a lite topic.
+   *
+   * <p>The subscribeLite() method initiates network requests and performs 
quota
+   * verification, so it may fail. It's important to check the result of this
+   * call to ensure that the subscription was successfully added. Possible
+   * failure scenarios include:</p>
+   * <ul>
+   *   <li>Network request errors, which can be retried.</li>
+   *   <li>Quota verification failures, indicated by 
LiteSubscriptionQuotaExceededException.
+   *       In this case, evaluate whether the quota is insufficient and 
promptly
+   *       unsubscribe from unused subscriptions using unsubscribeLite() to 
free
+   *       up resources.</li>
+   * </ul>
+   *
+   * @param liteTopic - The name of the lite topic to subscribe
+   * @throws ClientException if an error occurs during subscription
+   */
+  subscribeLite(liteTopic: string): Promise<void>;
+
+  /**
+   * Subscribe to a lite topic with an offset option to specify the consume 
from
+   * offset.
+   *
+   * @param liteTopic - The name of the lite topic to subscribe
+   * @param offsetOption - The consume from offset option
+   * @throws ClientException if an error occurs during subscription
+   */
+  subscribeLite(liteTopic: string, offsetOption: OffsetOption): Promise<void>;
+
+  /**
+   * Unsubscribe from a lite topic.
+   *
+   * @param liteTopic - The name of the lite topic to unsubscribe from
+   * @throws ClientException if an error occurs during unsubscription
+   */
+  unsubscribeLite(liteTopic: string): Promise<void>;
+
+  /**
+   * Get the lite topic immutable set.
+   *
+   * @return Lite topic immutable set
+   */
+  getLiteTopicSet(): Set<string>;
+
+  /**
+   * Fetch messages from the server synchronously.
+   *
+   * <p>This method returns immediately if there are messages available.
+   * Otherwise, it will await the passed timeout. If the timeout expires, an
+   * empty list will be returned.</p>
+   *
+   * @param maxMessageNum - Max message num of server returned
+   * @param invisibleDuration - Set the invisibleDuration of messages to return
+   * @return List of message views
+   */
+  receive(maxMessageNum?: number, invisibleDuration?: number): 
Promise<MessageView[]>;
+
+  /**
+   * Ack the consumption of the message which is returned by receive().
+   *
+   * @param messageView - Message view with the receipt handle to ack
+   * @throws ClientException if an error occurs during ack
+   */
+  ack(messageView: MessageView): Promise<void>;
+
+  /**
+   * Change the invisible duration of a received message.
+   *
+   * @param messageView - Message view to change invisible duration
+   * @param invisibleDuration - New invisible duration
+   * @throws ClientException if an error occurs
+   */
+  changeInvisibleDuration(messageView: MessageView, invisibleDuration: 
number): Promise<void>;
+
+  /**
+   * Close the consumer and release all related resources.
+   *
+   * <p>Once the consumer is closed, <strong>it could not be started once
+   * again.</strong></p>
+   */
+  close(): Promise<void>;
+}
+
+export interface LiteSimpleConsumerOptions extends BaseClientOptions {
+  consumerGroup: string;
+  /**
+   * The parent topic the lite consumer binds to. All lite topics subscribed by
+   * this consumer must belong to this parent topic.
+   */
+  bindTopic: string;
+  /**
+   * set await duration for long-polling, default is 30000ms
+   */
+  awaitDuration?: number;
+  /**
+   * max retry attempts for temporary errors (e.g., internal server error), 
default is 3
+   */
+  maxRetryAttempts?: number;
+}
+
+const CONSUMER_GROUP_PATTERN = /^[a-zA-Z0-9_-]+$/;
+
+/**
+ * LiteSimpleConsumer builder class.
+ *
+ * <p>This class provides a fluent API for configuring and creating lite simple
+ * consumers with explicit receive/ack control over lite topics.</p>
+ */
+export class LiteSimpleConsumerBuilder {
+  private options: Partial<LiteSimpleConsumerOptions> = {};
+
+  /**
+   * Set the bind topic for the lite simple consumer.
+   *
+   * @param bindTopic - The parent topic to bind
+   * @return This builder instance
+   * @throws Error if bindTopic is blank
+   */
+  bindTopic(bindTopic: string): LiteSimpleConsumerBuilder {
+    if (!bindTopic || bindTopic.trim().length === 0) {
+      throw new Error('bindTopic should not be blank');
+    }
+    this.options.bindTopic = bindTopic;
+    return this;
+  }
+
+  /**
+   * Set the client configuration.
+   *
+   * @param options - Client configuration options
+   * @return This builder instance
+   * @throws Error if options is null/undefined
+   */
+  setClientConfiguration(options: BaseClientOptions): 
LiteSimpleConsumerBuilder {
+    if (!options) {
+      throw new Error('clientConfiguration should not be null');
+    }
+    Object.assign(this.options, options);
+    return this;
+  }
+
+  /**
+   * Set the consumer group.
+   *
+   * @param consumerGroup - Consumer group name
+   * @return This builder instance
+   * @throws Error if consumerGroup is null or doesn't match the pattern
+   */
+  setConsumerGroup(consumerGroup: string): LiteSimpleConsumerBuilder {
+    if (!consumerGroup) {
+      throw new Error('consumerGroup should not be null');
+    }
+    if (!CONSUMER_GROUP_PATTERN.test(consumerGroup)) {
+      throw new Error(`consumerGroup does not match the pattern 
${CONSUMER_GROUP_PATTERN.source}`);
+    }
+    this.options.consumerGroup = consumerGroup;
+    return this;
+  }
+
+  /**
+   * Set the await duration for long-polling receive requests.
+   *
+   * @param awaitDuration - Maximum time to block when no message is available
+   * @return This builder instance
+   * @throws Error if awaitDuration is not positive
+   */
+  setAwaitDuration(awaitDuration: number): LiteSimpleConsumerBuilder {
+    if (awaitDuration <= 0) {
+      throw new Error('awaitDuration should be positive');
+    }
+    this.options.awaitDuration = awaitDuration;
+    return this;
+  }
+
+  /**
+   * Finalize the build of LiteSimpleConsumer and start.
+   *
+   * <p>This method will block until the lite simple consumer starts
+   * successfully.</p>
+   *
+   * @return Promise resolving to started LiteSimpleConsumer instance
+   * @throws Error if required parameters are not set
+   */
+  async build(): Promise<LiteSimpleConsumer> {
+    if (!this.options.endpoints) {
+      throw new Error('clientConfiguration has not been set yet');
+    }
+    if (!this.options.consumerGroup) {
+      throw new Error('consumerGroup has not been set yet');
+    }
+    if (!this.options.bindTopic) {
+      throw new Error('bindTopic has not been set yet');
+    }
+
+    const options: LiteSimpleConsumerOptions = {
+      endpoints: this.options.endpoints,
+      namespace: this.options.namespace ?? '',
+      consumerGroup: this.options.consumerGroup!,
+      bindTopic: this.options.bindTopic,
+      awaitDuration: this.options.awaitDuration,
+      maxRetryAttempts: this.options.maxRetryAttempts,
+      sslEnabled: this.options.sslEnabled,
+      sessionCredentials: this.options.sessionCredentials,
+      requestTimeout: this.options.requestTimeout,
+      logger: this.options.logger,
+    };
+
+    const liteSimpleConsumer = new LiteSimpleConsumerImpl(options);
+    await liteSimpleConsumer.startup();
+    return liteSimpleConsumer;
+  }
+}
diff --git a/nodejs/src/consumer/LiteSimpleConsumerImpl.ts 
b/nodejs/src/consumer/LiteSimpleConsumerImpl.ts
new file mode 100644
index 00000000..531e7907
--- /dev/null
+++ b/nodejs/src/consumer/LiteSimpleConsumerImpl.ts
@@ -0,0 +1,221 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ClientType, Permission, Settings as SettingsPB } from 
'../../proto/apache/rocketmq/v2/definition_pb';
+import { NotifyUnsubscribeLiteCommand } from 
'../../proto/apache/rocketmq/v2/service_pb';
+import { Endpoints, TopicRouteData } from '../route';
+import { Resource } from '../route/Resource';
+import { MASTER_BROKER_ID } from '../util';
+import { ILogger } from '../client/Logger';
+import { RpcClientManager } from '../client/RpcClientManager';
+import { SimpleConsumer, SimpleConsumerOptions } from './SimpleConsumer';
+import { FilterExpression } from './FilterExpression';
+import { LiteSubscriptionManager, LiteSubscriptionHost } from 
'./LiteSubscriptionManager';
+import { OffsetOption } from './OffsetOption';
+import { LiteSimpleConsumer, LiteSimpleConsumerOptions } from 
'./LiteSimpleConsumer';
+
+/**
+ * Keep only the first readable master queue of the route. Lite consumers talk
+ * to brokers only through the bound parent topic, so a single queue is enough
+ * and it avoids useless route bookkeeping for the other queues.
+ */
+export function firstReadableMasterQueue(topicRouteData: TopicRouteData): 
TopicRouteData {
+  const readable = topicRouteData.messageQueues.find(
+    mq => mq.broker.id === MASTER_BROKER_ID &&
+      (mq.permission === Permission.READ || mq.permission === 
Permission.READ_WRITE),
+  );
+  return new TopicRouteData(readable ? [ readable.toProtobuf() ] : []);
+}
+
+/**
+ * Implementation of LiteSimpleConsumer.
+ *
+ * <p>LiteSimpleConsumer extends SimpleConsumer to provide explicit receive/ack
+ * control over lite topics. It binds to a single parent topic and manages lite
+ * topic subscriptions dynamically via the lite subscription sync protocol.</p>
+ */
+export class LiteSimpleConsumerImpl extends SimpleConsumer
+  implements LiteSimpleConsumer, LiteSubscriptionHost {
+  readonly #bindTopic: Resource;
+  readonly #liteSubscriptionManager: LiteSubscriptionManager;
+
+  constructor(options: LiteSimpleConsumerOptions) {
+    if (!options.bindTopic || options.bindTopic.trim().length === 0) {
+      throw new TypeError('bindTopic should not be blank');
+    }
+    // Default subscription: (bindTopic, *) for code reuse.
+    const subscriptions = new Map<string, FilterExpression | string>()
+      .set(options.bindTopic, FilterExpression.SUB_ALL);
+
+    super({
+      ...options,
+      subscriptions,
+    } as SimpleConsumerOptions);
+
+    this.#bindTopic = new Resource(options.namespace, options.bindTopic);
+    const groupResource = new Resource(options.namespace, 
options.consumerGroup);
+    this.#liteSubscriptionManager = new LiteSubscriptionManager(this, 
this.#bindTopic, groupResource);
+  }
+
+  /**
+   * Get the client type.
+   *
+   * @return The client type identifier for lite simple consumer
+   */
+  protected getClientType(): ClientType {
+    return ClientType.LITE_SIMPLE_CONSUMER;
+  }
+
+  protected onTopicRouteDataUpdate(topic: string, topicRouteData: 
TopicRouteData) {
+    super.onTopicRouteDataUpdate(topic, 
firstReadableMasterQueue(topicRouteData));
+  }
+
+  /**
+   * Start up the consumer.
+   *
+   * <p>This method initializes the consumer and starts the lite subscription
+   * manager. It must be called before the consumer can receive messages.</p>
+   */
+  async startup() {
+    await super.startup();
+    this.#liteSubscriptionManager.startUp();
+  }
+
+  /**
+   * Shutdown the consumer.
+   *
+   * <p>This method gracefully shuts down the consumer, releasing all resources
+   * and stopping the lite subscription manager.</p>
+   */
+  async shutdown() {
+    this.#liteSubscriptionManager.shutdown();
+    await super.shutdown();
+  }
+
+  async subscribeLite(liteTopic: string): Promise<void>;
+
+  /**
+   * Subscribe to a lite topic with an offset option to specify the consume
+   * from offset.
+   *
+   * @param liteTopic - The name of the lite topic to subscribe
+   * @param offsetOption - The consume from offset option
+   */
+  async subscribeLite(liteTopic: string, offsetOption: OffsetOption): 
Promise<void>;
+
+  async subscribeLite(liteTopic: string, offsetOption?: OffsetOption): 
Promise<void> {
+    if (!liteTopic || liteTopic.trim().length === 0) {
+      throw new Error('liteTopic should not be blank');
+    }
+    await this.#liteSubscriptionManager.subscribeLite(liteTopic, offsetOption 
?? null);
+  }
+
+  /**
+   * Unsubscribe from a lite topic.
+   *
+   * @param liteTopic - The name of the lite topic to unsubscribe from
+   */
+  async unsubscribeLite(liteTopic: string): Promise<void> {
+    if (!liteTopic || liteTopic.trim().length === 0) {
+      throw new Error('liteTopic should not be blank');
+    }
+    await this.#liteSubscriptionManager.unsubscribeLite(liteTopic);
+  }
+
+  /**
+   * Get the lite topic set.
+   *
+   * @return Set of currently subscribed lite topic names
+   */
+  getLiteTopicSet(): Set<string> {
+    return this.#liteSubscriptionManager.getLiteTopicSet();
+  }
+
+  /**
+   * Get the load balancing group for the consumer.
+   *
+   * @return Consumer group name
+   */
+  getConsumerGroup(): string {
+    return this.#liteSubscriptionManager.getConsumerGroupName();
+  }
+
+  /**
+   * Handle notify unsubscribe lite command from server.
+   *
+   * @param _endpoints - The server endpoints
+   * @param command - The unsubscribe command from the server
+   */
+  onNotifyUnsubscribeLiteCommand(_endpoints: Endpoints, command: 
NotifyUnsubscribeLiteCommand) {
+    this.#liteSubscriptionManager.onNotifyUnsubscribeLiteCommand(command);
+  }
+
+  /**
+   * Handle settings command from server.
+   *
+   * @param endpoints - The server endpoints
+   * @param settings - The settings configuration
+   */
+  onSettingsCommand(endpoints: Endpoints, settings: SettingsPB) {
+    super.onSettingsCommand(endpoints, settings);
+    this.#liteSubscriptionManager.sync(settings);
+  }
+
+  /**
+   * Close the consumer.
+   */
+  async close(): Promise<void> {
+    await this.shutdown();
+  }
+
+  /**
+   * Get the logger.
+   *
+   * @internal
+   */
+  getLogger(): ILogger {
+    return this.logger;
+  }
+
+  /**
+   * Get the RPC client manager (protected access for internal use).
+   *
+   * @internal
+   */
+  getRpcClientManager(): RpcClientManager {
+    return this.rpcClientManager;
+  }
+
+  /**
+   * Get the request timeout (protected access for internal use).
+   *
+   * @internal
+   */
+  getRequestTimeout(): number {
+    return this.requestTimeout;
+  }
+
+  /**
+   * Endpoints the lite subscription manager should sync to: every endpoint
+   * present in the consumed routes.
+   *
+   * @internal
+   */
+  getSyncEndpoints(): Endpoints[] {
+    return this.getTotalRouteEndpoints();
+  }
+}
diff --git a/nodejs/src/consumer/LiteSubscriptionManager.ts 
b/nodejs/src/consumer/LiteSubscriptionManager.ts
index 2d89a13b..179bad71 100644
--- a/nodejs/src/consumer/LiteSubscriptionManager.ts
+++ b/nodejs/src/consumer/LiteSubscriptionManager.ts
@@ -21,15 +21,37 @@ import {
   SyncLiteSubscriptionRequest,
   SyncLiteSubscriptionResponse,
 } from '../../proto/apache/rocketmq/v2/service_pb';
+import { Endpoints } from '../route';
+import { ILogger } from '../client/Logger';
+import { RpcClientManager } from '../client/RpcClientManager';
 import { ClientException } from '../exception';
-import { Resource } from '../route';
-import { LitePushConsumerImpl } from './LitePushConsumerImpl';
+import { Resource } from '../route/Resource';
 import { OffsetOption } from './OffsetOption';
 
 const SYNC_LITE_SUBSCRIPTION_INTERVAL = 30000; // 30 seconds
 
 /**
- * Manages lite topic subscriptions for LitePushConsumer.
+ * Minimal host contract required by LiteSubscriptionManager.
+ *
+ * Implemented by LitePushConsumerImpl and LiteSimpleConsumerImpl so the same
+ * manager can keep lite subscriptions in sync for both consumer flavors.
+ */
+export interface LiteSubscriptionHost {
+  clientId: string;
+  isRunning(): boolean;
+  getLogger(): ILogger;
+  getRpcClientManager(): RpcClientManager;
+  getRequestTimeout(): number;
+  /**
+   * Endpoints the lite subscription should be synced to. Lite subscriptions
+   * must reach every route endpoint, not only the endpoint the client was
+   * configured with.
+   */
+  getSyncEndpoints(): Endpoints[];
+}
+
+/**
+ * Manages lite topic subscriptions for lite consumers.
  *
  * <p>LiteSubscriptionManager handles:
  * - Maintaining the set of subscribed lite topics
@@ -38,7 +60,7 @@ const SYNC_LITE_SUBSCRIPTION_INTERVAL = 30000; // 30 seconds
  * - Handling unsubscribe commands from server</p>
  */
 export class LiteSubscriptionManager {
-  private readonly consumerImpl: LitePushConsumerImpl;
+  private readonly host: LiteSubscriptionHost;
   private readonly bindTopic: Resource;
   private readonly group: Resource;
   private readonly liteTopicSet = new Set<string>();
@@ -47,11 +69,11 @@ export class LiteSubscriptionManager {
   private syncTimer?: NodeJS.Timeout;
 
   constructor(
-    consumerImpl: LitePushConsumerImpl,
+    host: LiteSubscriptionHost,
     bindTopic: Resource,
     group: Resource,
   ) {
-    this.consumerImpl = consumerImpl;
+    this.host = host;
     this.bindTopic = bindTopic;
     this.group = group;
     this.liteSubscriptionQuota = 100; // Default quota
@@ -129,7 +151,7 @@ export class LiteSubscriptionManager {
     offsetOption?: OffsetOption | null,
   ): Promise<void> {
     // Check if consumer is running
-    if (!this.consumerImpl.isRunning()) {
+    if (!this.host.isRunning()) {
       throw new ClientException(500, 'Consumer is not running');
     }
 
@@ -152,15 +174,15 @@ export class LiteSubscriptionManager {
       );
 
       this.liteTopicSet.add(liteTopic);
-      this.consumerImpl.getLogger().info(
+      this.host.getLogger().info(
         'SubscribeLite %s, topic=%s, group=%s, clientId=%s',
         liteTopic,
         this.getBindTopicName(),
         this.getConsumerGroupName(),
-        this.consumerImpl.clientId,
+        this.host.clientId,
       );
     } catch (error) {
-      this.consumerImpl.getLogger().error(
+      this.host.getLogger().error(
         'Failed to subscribeLite %s, error=%s',
         liteTopic,
         error,
@@ -174,7 +196,7 @@ export class LiteSubscriptionManager {
    */
   public async unsubscribeLite(liteTopic: string): Promise<void> {
     // Check if consumer is running
-    if (!this.consumerImpl.isRunning()) {
+    if (!this.host.isRunning()) {
       throw new ClientException(500, 'Consumer is not running');
     }
 
@@ -191,15 +213,15 @@ export class LiteSubscriptionManager {
       );
 
       this.liteTopicSet.delete(liteTopic);
-      this.consumerImpl.getLogger().info(
+      this.host.getLogger().info(
         'UnsubscribeLite %s, topic=%s, group=%s, clientId=%s',
         liteTopic,
         this.getBindTopicName(),
         this.getConsumerGroupName(),
-        this.consumerImpl.clientId,
+        this.host.clientId,
       );
     } catch (error) {
-      this.consumerImpl.getLogger().error(
+      this.host.getLogger().error(
         'Failed to unsubscribeLite %s, error=%s',
         liteTopic,
         error,
@@ -220,23 +242,23 @@ export class LiteSubscriptionManager {
         null,
       );
     } catch (error) {
-      this.consumerImpl.getLogger().error(
+      this.host.getLogger().error(
         'Schedule syncAllLiteSubscription error, clientId=%s, error=%s',
-        this.consumerImpl.clientId,
+        this.host.clientId,
         error,
       );
     }
   }
 
   /**
-   * Sync lite subscription with server.
+   * Sync lite subscription with every route endpoint.
    */
   private async syncLiteSubscription(
     action: LiteSubscriptionAction,
     liteTopics: string[],
     offsetOption: OffsetOption | null,
   ): Promise<void> {
-    const logger = this.consumerImpl.getLogger();
+    const logger = this.host.getLogger();
     if (logger.debug) {
       logger.debug(
         'SyncLiteSubscription: action=%s, liteTopics=[%s], offsetOption=%s',
@@ -258,29 +280,34 @@ export class LiteSubscriptionManager {
       request.setOffsetOption(offsetOption.toProtobuf());
     }
 
-    try {
-      // Call RPC client using public methods from LitePushConsumerImpl
-      const response: SyncLiteSubscriptionResponse = await 
this.consumerImpl.getRpcClientManager().syncLiteSubscription(
-        this.consumerImpl.getEndpoints(),
-        request,
-        this.consumerImpl.getRequestTimeout(),
-      );
+    const endpointsList = this.host.getSyncEndpoints();
+    if (endpointsList.length === 0) {
+      throw new ClientException(500, 'No endpoints available to sync lite 
subscription');
+    }
 
-      // Handle response status
-      const status = response.getStatus();
-      if (status && status.getCode() !== Code.OK) {
-        throw new ClientException(
-          status.getCode(),
-          `Failed to sync lite subscription: ${status.getMessage()}`,
-        );
-      }
+    try {
+      // The lite subscription must reach every route endpoint; the sync fails
+      // if any endpoint rejects it.
+      await Promise.all(endpointsList.map(async endpoints => {
+        const response: SyncLiteSubscriptionResponse = await 
this.host.getRpcClientManager()
+          .syncLiteSubscription(endpoints, request, 
this.host.getRequestTimeout());
+
+        // Handle response status
+        const status = response.getStatus();
+        if (status && status.getCode() !== Code.OK) {
+          throw new ClientException(
+            status.getCode(),
+            `Failed to sync lite subscription: ${status.getMessage()}`,
+          );
+        }
+      }));
 
       if (logger.info) {
         logger.info(
           'SyncLiteSubscription success: action=%s, liteTopics=[%s], 
clientId=%s',
           LiteSubscriptionAction[action],
           liteTopics.join(', '),
-          this.consumerImpl.clientId,
+          this.host.clientId,
         );
       }
     } catch (error) {
@@ -289,7 +316,7 @@ export class LiteSubscriptionManager {
           'SyncLiteSubscription failed: action=%s, liteTopics=[%s], 
clientId=%s, error=%s',
           LiteSubscriptionAction[action],
           liteTopics.join(', '),
-          this.consumerImpl.clientId,
+          this.host.clientId,
           error,
         );
       }
@@ -302,7 +329,7 @@ export class LiteSubscriptionManager {
    */
   public onNotifyUnsubscribeLiteCommand(command: NotifyUnsubscribeLiteCommand) 
{
     const liteTopic = command.getLiteTopic();
-    this.consumerImpl.getLogger().info(
+    this.host.getLogger().info(
       'Notify unsubscribe lite: liteTopic=%s, group=%s, bindTopic=%s',
       liteTopic,
       this.getConsumerGroupName(),
diff --git a/nodejs/src/consumer/index.ts b/nodejs/src/consumer/index.ts
index ff7a14ff..0d617401 100644
--- a/nodejs/src/consumer/index.ts
+++ b/nodejs/src/consumer/index.ts
@@ -34,6 +34,7 @@ export * from './LiteStandardConsumeService';
 export * from './ProcessQueue';
 export * from './PushConsumer';
 export * from './LitePushConsumer';
+export * from './LiteSimpleConsumer';
 export * from './OffsetOption';
 export { LitePushConsumerImpl } from './LitePushConsumerImpl';
 export { LiteSubscriptionManager } from './LiteSubscriptionManager';
diff --git a/nodejs/test/consumer/LiteSimpleConsumer.integration.test.ts 
b/nodejs/test/consumer/LiteSimpleConsumer.integration.test.ts
new file mode 100644
index 00000000..5f6d0bb3
--- /dev/null
+++ b/nodejs/test/consumer/LiteSimpleConsumer.integration.test.ts
@@ -0,0 +1,201 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Real-scenario integration tests for LiteSimpleConsumer.
+ *
+ * Requires a running proxy at the configured endpoints (default 
localhost:8081)
+ * with the following prerequisites:
+ * - the bind topic exists and is created with the LITE message type;
+ * - the consumer group exists and carries the attribute
+ *   lite.bind.topic=<bindTopic> (lite group).
+ *
+ * The local development broker must run with enableLmq=true and
+ * enableMultiDispatch=true so lite messages reach the LMQ queues.
+ */
+
+import { describe, it, afterEach } from 'node:test';
+import * as assert from 'node:assert';
+import { Producer } from '../../src';
+import { LiteSimpleConsumerImpl } from 
'../../src/consumer/LiteSimpleConsumerImpl';
+import { OffsetOption } from '../../src/consumer/OffsetOption';
+import { endpoints, namespace } from '../helper';
+
+const BIND_TOPIC = process.env.ROCKETMQ_NODEJS_CLIENT_LITE_TOPIC ?? 
'lite-parent-topic';
+const CONSUMER_GROUP = process.env.ROCKETMQ_NODEJS_CLIENT_LITE_GROUP ?? 
'nodejs-lite-unittest-group';
+
+describe('test/consumer/LiteSimpleConsumer.integration.test.ts', () => {
+  let producer: Producer | null = null;
+  let consumer: LiteSimpleConsumerImpl | null = null;
+
+  afterEach(async () => {
+    if (consumer) {
+      await consumer.shutdown().catch(() => undefined);
+      consumer = null;
+    }
+    if (producer) {
+      await producer.shutdown().catch(() => undefined);
+      producer = null;
+    }
+  });
+
+  async function startClientPair() {
+    producer = new Producer({
+      endpoints,
+      namespace,
+      topic: BIND_TOPIC,
+    });
+    await producer.startup();
+
+    consumer = new LiteSimpleConsumerImpl({
+      endpoints,
+      namespace,
+      consumerGroup: CONSUMER_GROUP,
+      bindTopic: BIND_TOPIC,
+      awaitDuration: 5000,
+    });
+    await consumer.startup();
+    assert.strictEqual(consumer.getConsumerGroup(), CONSUMER_GROUP);
+    assert.strictEqual(consumer.getLiteTopicSet().size, 0);
+  }
+
+  /**
+   * Send a lite message with retry. Right after the LITE topic is created, the
+   * proxy may serve a cached topic route without the message.type attribute,
+   * which makes the producer reject the send client-side. Refreshing the route
+   * cache and retrying bridges that propagation window.
+   */
+  async function sendLiteWithRetry(options: Parameters<Producer['send']>[0]) {
+    let lastErr: unknown = null;
+    const deadline = Date.now() + 30000;
+    while (Date.now() < deadline) {
+      try {
+        return await producer!.send(options);
+      } catch (err) {
+        const message = err instanceof Error ? err.message : String(err);
+        if (!message.includes('message type not match')) {
+          throw err;
+        }
+        lastErr = err;
+        await (producer as unknown as { updateRoutes(): Promise<void> 
}).updateRoutes();
+        await new Promise(resolve => setTimeout(resolve, 500));
+      }
+    }
+    throw lastErr ?? new Error('sendLiteWithRetry deadline exceeded');
+  }
+
+  it('should send and receive lite messages end-to-end', async () => {
+    await startClientPair();
+
+    const liteTopic = `lite-topic-it-${Date.now()}`;
+    await consumer!.subscribeLite(liteTopic, OffsetOption.MIN_OFFSET);
+    assert.ok(consumer!.getLiteTopicSet().has(liteTopic));
+
+    const sentIds: string[] = [];
+    for (let i = 0; i < 5; i++) {
+      const receipt = await sendLiteWithRetry({
+        topic: BIND_TOPIC,
+        liteTopic,
+        keys: [ `lite-it-key-${i}` ],
+        body: Buffer.from(`lite-it-body-${i}`),
+      });
+      assert.ok(receipt.messageId);
+      sentIds.push(receipt.messageId);
+    }
+    assert.strictEqual(sentIds.length, 5);
+
+    // Receive with a fixed invisible duration and ack every message.
+    const receivedIds: string[] = [];
+    const receivedBodies: string[] = [];
+    const deadline = Date.now() + 30000;
+    while (receivedIds.length < sentIds.length && Date.now() < deadline) {
+      const views = await consumer!.receive(10, 15000);
+      for (const view of views) {
+        receivedIds.push(view.messageId as string);
+        receivedBodies.push(Buffer.from(view.body as Uint8Array).toString());
+        await consumer!.ack(view);
+      }
+    }
+
+    assert.strictEqual(receivedIds.length, sentIds.length);
+    assert.deepStrictEqual(receivedIds.sort(), sentIds.sort());
+    for (let i = 0; i < 5; i++) {
+      assert.ok(receivedBodies.includes(`lite-it-body-${i}`));
+    }
+  });
+
+  it('should deliver messages sent after subscribeLite without offset option', 
async () => {
+    await startClientPair();
+
+    const liteTopic = `lite-topic-live-${Date.now()}`;
+    await consumer!.subscribeLite(liteTopic);
+    assert.ok(consumer!.getLiteTopicSet().has(liteTopic));
+
+    const sentIds: string[] = [];
+    for (let i = 0; i < 3; i++) {
+      const receipt = await sendLiteWithRetry({
+        topic: BIND_TOPIC,
+        liteTopic,
+        keys: [ `lite-live-key-${i}` ],
+        body: Buffer.from(`lite-live-body-${i}`),
+      });
+      sentIds.push(receipt.messageId as string);
+    }
+
+    const receivedIds: string[] = [];
+    const deadline = Date.now() + 30000;
+    while (receivedIds.length < sentIds.length && Date.now() < deadline) {
+      const views = await consumer!.receive(10, 15000);
+      for (const view of views) {
+        receivedIds.push(view.messageId as string);
+        await consumer!.ack(view);
+      }
+    }
+    assert.strictEqual(receivedIds.length, sentIds.length);
+    assert.deepStrictEqual(receivedIds.sort(), sentIds.sort());
+  });
+
+  it('should stop delivering after unsubscribeLite', async () => {
+    await startClientPair();
+
+    const liteTopic = `lite-topic-unsub-${Date.now()}`;
+    await consumer!.subscribeLite(liteTopic);
+    await consumer!.unsubscribeLite(liteTopic);
+    assert.strictEqual(consumer!.getLiteTopicSet().size, 0);
+
+    // Messages for the unsubscribed lite topic must not be delivered.
+    await producer!.send({
+      topic: BIND_TOPIC,
+      liteTopic,
+      keys: [ 'lite-unsub-key' ],
+      body: Buffer.from('lite-unsub-body'),
+    });
+
+    const views = await consumer!.receive(10, 15000);
+    assert.strictEqual(views.length, 0);
+  });
+
+  it('should reject blank lite topic names', async () => {
+    await startClientPair();
+    await assert.rejects(async () => {
+      await consumer!.subscribeLite('  ');
+    }, /liteTopic should not be blank/);
+    await assert.rejects(async () => {
+      await consumer!.unsubscribeLite('');
+    }, /liteTopic should not be blank/);
+  });
+});
diff --git a/nodejs/test/consumer/LiteSimpleConsumer.test.ts 
b/nodejs/test/consumer/LiteSimpleConsumer.test.ts
new file mode 100644
index 00000000..116bee05
--- /dev/null
+++ b/nodejs/test/consumer/LiteSimpleConsumer.test.ts
@@ -0,0 +1,374 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Offline tests for LiteSimpleConsumer (builder, impl, route filter) and
+ * LiteSubscriptionManager. No broker required.
+ */
+
+import { describe, it } from 'node:test';
+import * as assert from 'node:assert';
+import {
+  Broker as BrokerPB,
+  ClientType,
+  LiteSubscriptionAction,
+  MessageQueue as MessageQueuePB,
+  Permission,
+  Settings as SettingsPB,
+  Status,
+  Code,
+  Subscription as SubscriptionPB,
+} from '../../proto/apache/rocketmq/v2/definition_pb';
+import {
+  NotifyUnsubscribeLiteCommand,
+  SyncLiteSubscriptionResponse,
+} from '../../proto/apache/rocketmq/v2/service_pb';
+import { createResource } from '../../src/util';
+import { Endpoints, Resource, TopicRouteData } from '../../src/route';
+import { LiteSimpleConsumerBuilder } from 
'../../src/consumer/LiteSimpleConsumer';
+import {
+  LiteSimpleConsumerImpl,
+  firstReadableMasterQueue,
+} from '../../src/consumer/LiteSimpleConsumerImpl';
+import {
+  LiteSubscriptionManager,
+  LiteSubscriptionHost,
+} from '../../src/consumer/LiteSubscriptionManager';
+import { OffsetOption } from '../../src/consumer/OffsetOption';
+
+const ENDPOINTS = '127.0.0.1:8081';
+
+function buildProtoQueue(id: number, brokerId: number, permission: 
Permission): MessageQueuePB {
+  const broker = new BrokerPB()
+    .setName(`broker-${brokerId}`)
+    .setId(brokerId)
+    .setEndpoints(new Endpoints('127.0.0.1:10911').toProtobuf());
+  return new MessageQueuePB()
+    .setId(id)
+    .setTopic(createResource('lite-parent-topic'))
+    .setBroker(broker)
+    .setPermission(permission);
+}
+
+interface SyncRecord {
+  endpoints: Endpoints;
+  action: LiteSubscriptionAction;
+  liteTopics: string[];
+}
+
+class FakeHost implements LiteSubscriptionHost {
+  clientId = 'fake-client-id';
+  running = true;
+  quota: number | null = null;
+  maxLiteTopicSize: number | null = null;
+  readonly endpointsList = [ new Endpoints('127.0.0.1:8081'), new 
Endpoints('127.0.0.1:8082') ];
+  readonly syncRecords: SyncRecord[] = [];
+  readonly logger = {
+    info: () => undefined,
+    warn: () => undefined,
+    error: () => undefined,
+    debug: () => undefined,
+  };
+
+  isRunning(): boolean {
+    return this.running;
+  }
+
+  getLogger() {
+    return this.logger;
+  }
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  getRpcClientManager(): any {
+    return {
+      syncLiteSubscription: async (endpoints: Endpoints, request: any) => {
+        this.syncRecords.push({
+          endpoints,
+          action: request.getAction(),
+          liteTopics: request.getLiteTopicSetList(),
+        });
+        return new SyncLiteSubscriptionResponse().setStatus(new 
Status().setCode(Code.OK));
+      },
+    };
+  }
+
+  getRequestTimeout(): number {
+    return 3000;
+  }
+
+  getSyncEndpoints(): Endpoints[] {
+    return this.endpointsList;
+  }
+
+  resetRecords() {
+    this.syncRecords.length = 0;
+  }
+}
+
+describe('test/consumer/LiteSimpleConsumer.test.ts', () => {
+
+  describe('LiteSimpleConsumerBuilder', () => {
+    it('should chain bindTopic and validate blank topic', () => {
+      const builder = new LiteSimpleConsumerBuilder();
+      assert.strictEqual(builder.bindTopic('test-bind-topic'), builder);
+      assert.throws(() => builder.bindTopic(''), /bindTopic should not be 
blank/);
+      assert.throws(() => builder.bindTopic('   '), /bindTopic should not be 
blank/);
+    });
+
+    it('should validate consumer group', () => {
+      const builder = new LiteSimpleConsumerBuilder();
+      assert.throws(() => builder.setConsumerGroup(null as any), 
/consumerGroup should not be null/);
+      assert.throws(() => builder.setConsumerGroup('invalid group!'), /does 
not match the pattern/);
+      assert.strictEqual(builder.setConsumerGroup('valid-group_1'), builder);
+    });
+
+    it('should validate await duration', () => {
+      const builder = new LiteSimpleConsumerBuilder();
+      assert.throws(() => builder.setAwaitDuration(0), /awaitDuration should 
be positive/);
+      assert.throws(() => builder.setAwaitDuration(-1), /awaitDuration should 
be positive/);
+      assert.strictEqual(builder.setAwaitDuration(5000), builder);
+    });
+
+    it('should fail to build without required options', async () => {
+      await assert.rejects(async () => {
+        await new LiteSimpleConsumerBuilder().build();
+      }, /clientConfiguration has not been set yet/);
+
+      await assert.rejects(async () => {
+        await new LiteSimpleConsumerBuilder().setClientConfiguration({ 
endpoints: ENDPOINTS, namespace: '' }).build();
+      }, /consumerGroup has not been set yet/);
+
+      await assert.rejects(async () => {
+        await new LiteSimpleConsumerBuilder()
+          .setClientConfiguration({ endpoints: ENDPOINTS, namespace: '' })
+          .setConsumerGroup('valid-group')
+          .build();
+      }, /bindTopic has not been set yet/);
+    });
+  });
+
+  describe('LiteSimpleConsumerImpl constructor', () => {
+    const baseOptions = {
+      endpoints: ENDPOINTS,
+      namespace: '',
+      consumerGroup: 'lite-unittest-group',
+      bindTopic: 'lite-parent-topic',
+    };
+
+    it('should reject blank bindTopic', () => {
+      assert.throws(() => {
+        new LiteSimpleConsumerImpl({ ...baseOptions, bindTopic: ' ' });
+      }, /bindTopic should not be blank/);
+    });
+
+    it('should use LITE_SIMPLE_CONSUMER client type', () => {
+      const impl = new LiteSimpleConsumerImpl(baseOptions);
+      assert.strictEqual((impl as any).getClientType(), 
ClientType.LITE_SIMPLE_CONSUMER);
+    });
+
+    it('should subscribe the bind topic with SUB_ALL by default', () => {
+      const impl = new LiteSimpleConsumerImpl(baseOptions);
+      const settings = (impl as any).getSettings();
+      const protobuf = settings.toProtobuf();
+      const subscription = protobuf.getSubscription()!;
+      assert.strictEqual(subscription.getGroup()!.getName(), 
'lite-unittest-group');
+      const entries = subscription.getSubscriptionsList();
+      assert.strictEqual(entries.length, 1);
+      assert.strictEqual(entries[0].getTopic()!.getName(), 
'lite-parent-topic');
+      assert.strictEqual(entries[0].getExpression()!.getExpression(), '*');
+      assert.strictEqual(protobuf.getClientType(), 
ClientType.LITE_SIMPLE_CONSUMER);
+    });
+
+    it('should expose group and empty lite topic set before startup', () => {
+      const impl = new LiteSimpleConsumerImpl(baseOptions);
+      assert.strictEqual(impl.getConsumerGroup(), 'lite-unittest-group');
+      assert.strictEqual(impl.getLiteTopicSet().size, 0);
+    });
+  });
+
+  describe('firstReadableMasterQueue', () => {
+    it('should keep only the first readable master queue', () => {
+      const routeData = new TopicRouteData([
+        buildProtoQueue(0, 1, Permission.READ_WRITE), // slave readable
+        buildProtoQueue(1, 0, Permission.WRITE), // master not readable
+        buildProtoQueue(2, 0, Permission.READ_WRITE), // first readable master
+        buildProtoQueue(3, 0, Permission.READ), // second readable master
+      ]);
+
+      const filtered = firstReadableMasterQueue(routeData);
+      assert.strictEqual(filtered.messageQueues.length, 1);
+      assert.strictEqual(filtered.messageQueues[0].broker.id, 0);
+      assert.strictEqual(filtered.messageQueues[0].queueId, 2);
+    });
+
+    it('should return empty route when no readable master exists', () => {
+      const routeData = new TopicRouteData([
+        buildProtoQueue(0, 0, Permission.WRITE),
+        buildProtoQueue(1, 1, Permission.READ),
+      ]);
+
+      const filtered = firstReadableMasterQueue(routeData);
+      assert.strictEqual(filtered.messageQueues.length, 0);
+    });
+  });
+
+  describe('LiteSubscriptionManager', () => {
+    const bindTopic = 'lite-parent-topic';
+
+    function createManager(host = new FakeHost()) {
+      const manager = new LiteSubscriptionManager(
+        host,
+        new Resource('', bindTopic),
+        new Resource('', 'lite-unittest-group'),
+      );
+      return { host, manager };
+    }
+
+    it('should sync PARTIAL_ADD to every sync endpoint on subscribeLite', 
async () => {
+      const { host, manager } = createManager();
+      await manager.subscribeLite('lite-topic-1', OffsetOption.MIN_OFFSET);
+
+      assert.strictEqual(host.syncRecords.length, 2);
+      for (const record of host.syncRecords) {
+        assert.strictEqual(record.action, LiteSubscriptionAction.PARTIAL_ADD);
+        assert.deepStrictEqual(record.liteTopics, [ 'lite-topic-1' ]);
+      }
+      assert.ok(host.syncRecords.some(r => r.endpoints.facade === 
'127.0.0.1:8081'));
+      assert.ok(host.syncRecords.some(r => r.endpoints.facade === 
'127.0.0.1:8082'));
+      assert.ok(manager.getLiteTopicSet().has('lite-topic-1'));
+      assert.strictEqual(manager.getBindTopicName(), bindTopic);
+      assert.strictEqual(manager.getConsumerGroupName(), 
'lite-unittest-group');
+    });
+
+    it('should reject subscribeLite when not running', async () => {
+      const { host, manager } = createManager();
+      host.running = false;
+      await assert.rejects(async () => {
+        await manager.subscribeLite('lite-topic-1');
+      }, /Consumer is not running/);
+      assert.strictEqual(host.syncRecords.length, 0);
+    });
+
+    it('should skip duplicate subscribeLite without extra rpc', async () => {
+      const { host, manager } = createManager();
+      await manager.subscribeLite('lite-topic-1');
+      host.resetRecords();
+      await manager.subscribeLite('lite-topic-1');
+      assert.strictEqual(host.syncRecords.length, 0);
+      assert.strictEqual(manager.getLiteTopicSet().size, 1);
+    });
+
+    it('should validate lite topic name and length', async () => {
+      const { manager } = createManager();
+      await assert.rejects(async () => {
+        await manager.subscribeLite('   ');
+      }, /liteTopic is blank/);
+      await assert.rejects(async () => {
+        await manager.subscribeLite('x'.repeat(65));
+      }, /liteTopic length exceeded max length 64/);
+    });
+
+    it('should throw when quota exceeded', async () => {
+      const { manager } = createManager();
+      const settings = new SettingsPB()
+        .setSubscription(new SubscriptionPB().setLiteSubscriptionQuota(1));
+      manager.sync(settings);
+      await manager.subscribeLite('lite-topic-1');
+      await assert.rejects(async () => {
+        await manager.subscribeLite('lite-topic-2');
+      }, /Lite subscription quota exceeded 1/);
+      assert.strictEqual(manager.getLiteTopicSet().size, 1);
+    });
+
+    it('should adopt maxLiteTopicSize from settings', async () => {
+      const { manager } = createManager();
+      const settings = new SettingsPB()
+        .setSubscription(new SubscriptionPB().setMaxLiteTopicSize(8));
+      manager.sync(settings);
+      await assert.rejects(async () => {
+        await manager.subscribeLite('x'.repeat(9));
+      }, /liteTopic length exceeded max length 8/);
+    });
+
+    it('should sync PARTIAL_REMOVE on unsubscribeLite', async () => {
+      const { host, manager } = createManager();
+      await manager.subscribeLite('lite-topic-1');
+      host.resetRecords();
+
+      await manager.unsubscribeLite('lite-topic-1');
+      assert.strictEqual(host.syncRecords.length, 2);
+      assert.ok(host.syncRecords.every(r => r.action === 
LiteSubscriptionAction.PARTIAL_REMOVE));
+      assert.strictEqual(manager.getLiteTopicSet().size, 0);
+    });
+
+    it('should skip unsubscribeLite for unknown topic', async () => {
+      const { host, manager } = createManager();
+      await manager.unsubscribeLite('never-subscribed');
+      assert.strictEqual(host.syncRecords.length, 0);
+    });
+
+    it('should fail subscription when any endpoint rejects the sync', async () 
=> {
+      const host = new FakeHost();
+      const manager = new LiteSubscriptionManager(
+        host,
+        new Resource('', bindTopic),
+        new Resource('', 'lite-unittest-group'),
+      );
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      (host as any).getRpcClientManager = () => ({
+        syncLiteSubscription: async (endpoints: Endpoints) => {
+          if (endpoints.facade === '127.0.0.1:8081') {
+            return new SyncLiteSubscriptionResponse().setStatus(
+              new Status().setCode(Code.INTERNAL_SERVER_ERROR),
+            );
+          }
+          return new SyncLiteSubscriptionResponse().setStatus(new 
Status().setCode(Code.OK));
+        },
+      });
+
+      await assert.rejects(async () => {
+        await manager.subscribeLite('lite-topic-1');
+      }, /Failed to sync lite subscription/);
+      // Failed sync must not pollute the local set
+      assert.strictEqual(manager.getLiteTopicSet().size, 0);
+    });
+
+    it('should remove lite topic on notify unsubscribe command', async () => {
+      const { manager } = createManager();
+      await manager.subscribeLite('lite-topic-1');
+      const command = new 
NotifyUnsubscribeLiteCommand().setLiteTopic('lite-topic-1');
+      manager.onNotifyUnsubscribeLiteCommand(command);
+      assert.strictEqual(manager.getLiteTopicSet().size, 0);
+    });
+
+    it('should ignore blank lite topic in notify unsubscribe command', async 
() => {
+      const { manager } = createManager();
+      await manager.subscribeLite('lite-topic-1');
+      manager.onNotifyUnsubscribeLiteCommand(new 
NotifyUnsubscribeLiteCommand().setLiteTopic(''));
+      assert.ok(manager.getLiteTopicSet().has('lite-topic-1'));
+    });
+
+    it('should shutdown cleanly and clear the topic set', async () => {
+      const { manager } = createManager();
+      await manager.subscribeLite('lite-topic-1');
+      manager.startUp();
+      manager.shutdown();
+      assert.strictEqual(manager.getLiteTopicSet().size, 0);
+      manager.shutdown(); // idempotent
+    });
+  });
+});

Reply via email to