UNPKG

@lightbend/akkaserverless-javascript-sdk

Version:
757 lines (705 loc) 21.5 kB
/* * Copyright 2021 Lightbend Inc. * * Licensed 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. */ const path = require('path'); const grpc = require('@grpc/grpc-js'); const protoLoader = require('@grpc/proto-loader'); const debug = require('debug')('akkaserverless-action'); // Bind to stdout debug.log = console.log.bind(console); const AnySupport = require('./protobuf-any'); const EffectSerializer = require('./effect-serializer'); const { Metadata } = require('./metadata'); const { Reply } = require('./reply'); class ActionSupport { constructor(root, service, commandHandlers, allComponents) { this.root = root; this.service = service; this.commandHandlers = commandHandlers; this.anySupport = new AnySupport(this.root); this.effectSerializer = new EffectSerializer(allComponents); } } /** * @private */ class ActionHandler { constructor( support, grpcMethod, commandHandler, call, grpcCallback, metadata, ) { this.support = support; this.grpcMethod = grpcMethod; this.commandHandler = commandHandler; this.call = call; this.grpcCallback = grpcCallback; this.streamId = Math.random().toString(16).substr(2, 7); this.streamDebug('Started new call'); this.supportedEvents = []; this.callbacks = {}; this.ctx = this.createContext(metadata); } streamDebug(msg, ...args) { debug( '%s [%s.%s] - ' + msg, ...[ this.streamId, this.support.service.name, this.grpcMethod.name, ].concat(args), ); } /** * Context for an action command. * * @interface module:akkaserverless.Action.ActionCommandContext * @extends module:akkaserverless.CommandContext * @property {boolean} cancelled Whether the client is still connected. * @property {module:akkaserverless.Metadata} metadata The metadata associated with the command. */ createContext(metadata) { /** * Write a message. * * @function module:akkaserverless.Action.ActionCommandContext#write * @param {Object} message The protobuf message to write. * @param {module:akkaserverless.Metadata} [metadata] The metadata associated with the message. */ const call = this.call; let metadataObject = new Metadata([]); if (metadata && metadata.entries) { metadataObject = new Metadata(metadata.entries); } const ctx = { get cancelled() { return call.cancelled; }, get metadata() { return metadataObject; }, }; /** * Register an event handler. * * @function module:akkaserverless.Action.ActionCommandContext#on * @param {string} eventType The type of the event. * @param {function} callback The callback to handle the event. */ ctx.on = (eventType, callback) => { if (this.supportedEvents.includes(eventType)) { this.callbacks[eventType] = callback; } else { throw new Error('Unknown event type: ' + eventType); } }; return ctx; } /** * @return {*} The return value from the callback, if there was one */ invokeCallback(eventType, ...args) { if (this.callbacks.hasOwnProperty(eventType)) { return this.invokeUserCallback( eventType + ' event', this.callbacks[eventType], ...args, ); } } ensureNotCancelled() { if (this.call.cancelled) { throw new Error( 'Already replied to unary command, cannot interact further.', ); } } /** * @param {module:akkaserverless.Action.ActionCommandContext} ctx * @param {module:akkaserverless.replies.Reply} reply */ passReplyThroughContext(ctx, reply) { // effects need to go first to end up in reply if (reply.effects) { reply.effects.forEach(function (effect) { ctx.effect( effect.method, effect.message, effect.synchronous, effect.metadata, true, ); }); } if (reply.failure) { ctx.fail(reply.failure.description, reply.failure.status); } else if (reply.message) { ctx.write(reply.message, reply.metadata); } else if (reply.forward) { ctx.forward( reply.forward.method, reply.forward.message, reply.forward.metadata, true, ); } else { // no reply ctx.write(null); } } handleSingleReturn(value) { if (value) { if (this.ctx.alreadyReplied) { console.warn( `WARNING: Action handler for ${this.support.service.name}.${this.grpcMethod.name} both sent a reply through the context and returned a value, ignoring return value.`, ); } else if (value instanceof Reply) { this.passReplyThroughContext(this.ctx, value); } else if (typeof value.then === 'function') { value.then(this.handleSingleReturn.bind(this), this.ctx.fail); } else { this.ctx.write(value); } } else if (!this.ctx.alreadyReplied) { this.ctx.write({}); // empty reply, resolved to response type } } /** * Context for a unary action command. * * @interface module:akkaserverless.Action.UnaryCommandContext * @extends module:akkaserverless.Action.ActionCommandContext */ handleUnary() { this.setupUnaryOutContext(); const deserializedCommand = this.support.anySupport.deserialize( this.call.request.payload, ); const userReturn = this.invokeUserCallback( 'command', this.commandHandler, deserializedCommand, this.ctx, ); this.handleSingleReturn(userReturn); } /** * Context for a streamed in action command. * * @interface module:akkaserverless.Action.StreamedInCommandContext * @extends module:akkaserverless.Action.StreamedInContext * @extends module:akkaserverless.Action.ActionCommandContext */ handleStreamedIn() { this.setupUnaryOutContext(); this.setupStreamedInContext(); const userReturn = this.invokeUserCallback( 'command', this.commandHandler, this.ctx, ); if (userReturn !== undefined) { if (this.call.cancelled) { this.streamDebug( 'Streamed command handler for command %s.%s both sent a reply through the context and returned a value, ignoring return value.', this.support.service.name, this.grpcMethod.name, ); } else { if (typeof userReturn.then === 'function') { userReturn.then(this.ctx.write, this.ctx.fail); } else { this.ctx.write(userReturn); } } } } /** * Context for a streamed out action command. * * @interface module:akkaserverless.Action.StreamedOutCommandContext * @extends module:akkaserverless.Action.StreamedOutContext */ handleStreamedOut() { this.setupStreamedOutContext(); const deserializedCommand = this.support.anySupport.deserialize( this.call.request.payload, ); this.invokeUserCallback( 'command', this.commandHandler, deserializedCommand, this.ctx, ); } /** * Context for a streamed action command. * * @interface module:akkaserverless.Action.StreamedCommandContext * @extends module:akkaserverless.Action.StreamedInContext * @extends module:akkaserverless.Action.StreamedOutContext */ handleStreamed() { this.setupStreamedInContext(); this.setupStreamedOutContext(); this.invokeUserCallback('command', this.commandHandler, this.ctx); } setupUnaryOutContext() { const effects = []; // FIXME: remove for version 0.8 (https://github.com/lightbend/akkaserverless-framework/issues/410) this.ctx.thenForward = (method, message, metadata) => { console.warn( "WARNING: Action context 'thenForward' is deprecated. Please use 'forward' instead.", ); this.ctx.forward(method, message, metadata, true); }; /** * DEPRECATED. Forward this command to another service component call, use 'ReplyFactory.forward' instead. * * @function module:akkaserverless.Action.UnaryCommandContext#forward * @param method The service component method to invoke. * @param {object} message The message to send to that service component. * @param {module:akkaserverless.Metadata} metadata Metadata to send with the forward. */ this.ctx.forward = (method, message, metadata, internalCall) => { this.ensureNotCancelled(); this.streamDebug('Forwarding to %s', method); this.ctx.alreadyReplied = true; if (!internalCall) console.warn( "WARNING: Action context 'forward' is deprecated. Please use 'ReplyFactory.forward' instead.", ); const forward = this.support.effectSerializer.serializeEffect( method, message, metadata, ); this.grpcCallback(null, { forward: forward, sideEffects: effects, }); }; this.ctx.write = (message, metadata) => { this.ensureNotCancelled(); this.streamDebug('Sending reply'); this.ctx.alreadyReplied = true; if (message != null) { let replyPayload; if ( this.grpcMethod.resolvedResponseType.fullName === '.google.protobuf.Any' ) { // special handling to emit JSON to topics by defining return type as proto Any replyPayload = AnySupport.serialize(message, false, true); } else { const messageProto = this.grpcMethod.resolvedResponseType.create(message); replyPayload = AnySupport.serialize(messageProto, false, false); } let replyMetadata = null; if (metadata && metadata.entries) { replyMetadata = { entries: metadata.entries, }; } this.grpcCallback(null, { reply: { payload: replyPayload, metadata: replyMetadata, }, sideEffects: effects, }); } else { // empty reply this.grpcCallback(null, { sideEffects: effects, }); } }; this.ctx.effect = ( method, message, synchronous, metadata, internalCall, ) => { this.ensureNotCancelled(); if (!internalCall) console.warn( "WARNING: Action context 'effect' is deprecated. Please use 'Reply.addEffect' instead.", ); this.streamDebug('Emitting effect to %s', method); effects.push( this.support.effectSerializer.serializeSideEffect( method, message, synchronous, metadata, ), ); }; this.ctx.fail = (description, status) => { this.ensureNotCancelled(); this.streamDebug('Failing with %s', description); this.ctx.alreadyReplied = true; this.grpcCallback(null, { failure: this.createFailure(description, status), sideEffects: effects, }); }; } /** * Context for an action command that returns a streamed message out. * * @interface module:akkaserverless.Action.StreamedOutContext * @extends module:akkaserverless.Action.ActionCommandContext */ setupStreamedOutContext() { let effects = []; /** * A cancelled event. * * @event module:akkaserverless.Action.StreamedOutContext#cancelled */ this.supportedEvents.push('cancelled'); this.call.on('cancelled', () => { this.streamDebug('Received stream cancelled'); this.invokeCallback('cancelled', this.ctx); }); /** * Send a reply * * @function module:akkaserverless.Action.StreamedOutContext#reply * @param {module:akkaserverless.replies.Reply} reply The reply to send */ this.ctx.reply = (reply) => { this.passReplyThroughContext(this.ctx, reply); }; /** * Terminate the outgoing stream of messages. * * @function module:akkaserverless.Action.StreamedOutContext#end */ this.ctx.end = () => { if (this.call.cancelled) { this.streamDebug('end invoked when already cancelled.'); } else { this.streamDebug('Ending stream out'); this.call.end(); } }; // FIXME: remove for version 0.8 (https://github.com/lightbend/akkaserverless-framework/issues/410) this.ctx.thenForward = (method, message, metadata) => { console.warn( "WARNING: Action context 'thenForward' is deprecated. Please use 'forward' instead.", ); this.ctx.forward(method, message, metadata); }; this.ctx.forward = (method, message, metadata) => { this.ensureNotCancelled(); this.streamDebug('Forwarding to %s', method); const forward = this.support.effectSerializer.serializeEffect( method, message, metadata, ); this.call.write({ forward: forward, sideEffects: effects, }); effects = []; // clear effects after each streamed write }; this.ctx.write = (message, metadata) => { this.ensureNotCancelled(); this.streamDebug('Sending reply'); if (message != null) { const messageProto = this.grpcMethod.resolvedResponseType.create(message); const replyPayload = AnySupport.serialize(messageProto, false, false); let replyMetadata = null; if (metadata && metadata.entries) { replyMetadata = { entries: metadata.entries, }; } this.call.write({ reply: { payload: replyPayload, metadata: replyMetadata, }, sideEffects: effects, }); } else { // empty reply this.call.write({ sideEffects: effects, }); } effects = []; // clear effects after each streamed write }; this.ctx.effect = ( method, message, synchronous, metadata, internalCall, ) => { this.ensureNotCancelled(); if (!internalCall) console.warn( "WARNING: Action context 'effect' is deprecated. Please use 'Reply.addEffect' instead.", ); this.streamDebug('Emitting effect to %s', method); effects.push( this.support.effectSerializer.serializeSideEffect( method, message, synchronous, metadata, ), ); }; this.ctx.fail = (description, status) => { this.ensureNotCancelled(); this.streamDebug('Failing with %s', description); this.call.write({ failure: this.createFailure(description, status), sideEffects: effects, }); effects = []; // clear effects after each streamed write }; } /** * Context for an action command that handles streamed messages in. * * @interface module:akkaserverless.Action.StreamedInContext * @extends module:akkaserverless.Action.ActionCommandContext */ setupStreamedInContext() { /** * A data event. * * Emitted when a new message arrives. * * @event module:akkaserverless.Action.StreamedInContext#data * @type {Object} */ this.supportedEvents.push('data'); /** * A stream end event. * * Emitted when the input stream terminates. * * If a callback is registered and that returns a Reply, then that is returned as a response from the action * * @event module:akkaserverless.Action.StreamedInContext#end */ this.supportedEvents.push('end'); this.call.on('data', (data) => { this.streamDebug('Received data in'); const deserializedCommand = this.support.anySupport.deserialize( data.payload, ); this.invokeCallback('data', deserializedCommand, this.ctx); }); this.call.on('end', () => { this.streamDebug('Received stream end'); const userReturn = this.invokeCallback('end', this.ctx); if (userReturn instanceof Reply) { this.passReplyThroughContext(this.ctx, userReturn); } else { this.streamDebug( 'Ignored unknown (non Reply) return value from end callback', ); } }); /** * Cancel the incoming stream of messages. * * @function module:akkaserverless.Action.StreamedInContext#cancel */ this.ctx.cancel = () => { if (this.call.cancelled) { this.streamDebug('cancel invoked when already cancelled.'); } else { this.call.cancel(); } }; } invokeUserCallback(callbackName, callback, ...args) { try { return callback.apply(null, args); } catch (err) { const error = 'Error handling ' + callbackName; this.streamDebug(error); console.error(err); if (!this.call.cancelled) { const failure = { failure: { description: error, }, }; if (this.grpcCallback != null) { this.grpcCallback(null, failure); } else { this.call.write(failure); this.call.end(); } } } } createFailure(description, grpcStatus) { const failure = { description: description, }; if (grpcStatus !== undefined) { if (grpcStatus === 0) { throw new Error('gRPC failure status code must not be OK'); } if (grpcStatus < 0 || grpcStatus > 16) { throw new Error('Invalid gRPC status code: ' + grpcStatus); } failure.grpcStatusCode = grpcStatus; } return failure; } } module.exports = class ActionServices { constructor() { this.services = {}; } addService(component, allComponents) { this.services[component.serviceName] = new ActionSupport( component.root, component.service, component.commandHandlers, allComponents, ); } componentType() { return 'akkaserverless.component.action.Actions'; } register(server) { const includeDirs = [ path.join(__dirname, '..', 'proto'), path.join(__dirname, '..', 'protoc', 'include'), path.join(__dirname, '..', '..', 'proto'), path.join(__dirname, '..', '..', 'protoc', 'include'), ]; const packageDefinition = protoLoader.loadSync( path.join('akkaserverless', 'component', 'action', 'action.proto'), { includeDirs: includeDirs, }, ); const grpcDescriptor = grpc.loadPackageDefinition(packageDefinition); const actionService = grpcDescriptor.akkaserverless.component.action.Actions.service; server.addService(actionService, { handleUnary: this.handleUnary.bind(this), handleStreamedIn: this.handleStreamedIn.bind(this), handleStreamedOut: this.handleStreamedOut.bind(this), handleStreamed: this.handleStreamed.bind(this), }); } createHandler(call, callback, data) { const service = this.services[data.serviceName]; if (service && service.service.methods.hasOwnProperty(data.name)) { if (service.commandHandlers.hasOwnProperty(data.name)) { return new ActionHandler( service, service.service.methods[data.name], service.commandHandlers[data.name], call, callback, data.metadata, ); } else { this.reportError( 'Service call ' + data.serviceName + '.' + data.name + ' not implemented', call, callback, ); } } else { this.reportError( 'No service call named ' + data.serviceName + '.' + data.name + ' found', call, callback, ); } } reportError(error, call, callback) { console.warn(error); const failure = { failure: { description: error, }, }; if (callback !== null) { callback(null, failure); } else { call.write(failure); call.end(); } } handleStreamed(call) { let initial = true; call.on('data', (data) => { if (initial) { initial = false; const handler = this.createHandler(call, null, data); if (handler) { handler.handleStreamed(); } } // ignore the remaining data here, subscribed in setupStreamedInContext }); } handleStreamedOut(call) { const handler = this.createHandler(call, null, call.request); if (handler) { handler.handleStreamedOut(); } } handleStreamedIn(call, callback) { let initial = true; call.on('data', (data) => { if (initial) { initial = false; const handler = this.createHandler(call, callback, data); if (handler) { handler.handleStreamedIn(); } } // ignore the remaining data here, subscribed in setupStreamedInContext }); } handleUnary(call, callback) { const handler = this.createHandler(call, callback, call.request); if (handler) { handler.handleUnary(); } } };