commit dabc1b9bd72efeead2906b03ce7a1eeeeb3bc1cd Author: LumbarJam Date: Tue Dec 9 15:41:17 2025 -0300 Initial commit: Docker Sandbox Runner complete setup diff --git a/.DS_Store b/.DS_Store new file mode 100755 index 0000000..173ddf9 Binary files /dev/null and b/.DS_Store differ diff --git a/._.DS_Store b/._.DS_Store new file mode 100755 index 0000000..28c42fb Binary files /dev/null and b/._.DS_Store differ diff --git a/.___init__.py b/.___init__.py new file mode 100755 index 0000000..c02edfb Binary files /dev/null and b/.___init__.py differ diff --git a/._example_workflow.json b/._example_workflow.json new file mode 100755 index 0000000..bdf878a Binary files /dev/null and b/._example_workflow.json differ diff --git a/._sandbox_node.py b/._sandbox_node.py new file mode 100755 index 0000000..83c3db0 Binary files /dev/null and b/._sandbox_node.py differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a47f38 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# Docker Sandbox Runner for ComfyUI + +This **Custom Node** provides a secure, isolated execution environment for Python scripts within ComfyUI, utilizing persistent Docker containers. Code execution is ultra-fast (milliseconds) and completely isolated from the host operating system. + +----- + +## Key Features + + * **Maximum Security (Docker Isolation):** Code runs in an ephemeral Docker container, blocked from accessing the Host's filesystem, network, or sensitive resources. + * **Performance (Sidecar Pattern):** The container is started only once (the *Sidecar* pattern) and reused via `docker exec`, eliminating Docker boot time latency. + * **Dynamic Inputs:** Allows adding execution inputs (`*`, `STRING`, `INT`, etc.) dynamically via the context menu. + * **Multiple Outputs:** Provides 6 result outputs (`r1` to `r6`) and 1 log output (`log`). + * **Non-Root Execution:** Code runs as the **`nobody`** user inside the container, significantly minimizing internal security risk. + +----- + +## Installation + +### 1\. Mandatory Prerequisites + +You must have two pieces of software installed: + + * **Docker Desktop/Engine:** The containerization engine itself. + * **`docker-py`:** The Python library to control Docker. + +Execute the following command in your ComfyUI Python environment: + +```bash +# Installs the Python library required to communicate with the Docker daemon +pip install docker +``` + +### 2\. Node Installation + +1. Clone this repository into your ComfyUI's `custom_nodes` folder: + ```bash + cd path/to/ComfyUI/custom_nodes/ + git clone [Your Repository URL] + ``` +2. Restart ComfyUI. + +----- + +## Docker Engine Installation (Detailed CLI Instructions) + +You must install and start the Docker service (Daemon) before using the node. **Note:** After installation, ensure your user is added to the `docker` group to run commands without `sudo` (e.g., `sudo usermod -aG docker $USER`). + +| OS / Distribution | Installation Command Set (Root/Sudo Required) | Verification & Service | +| :--- | :--- | :--- | +| **Ubuntu/Debian** | **1. Install Prerequisites:**
`sudo apt update && sudo apt install ca-certificates curl gnupg`
**2. Add GPG Key:**
`sudo install -m 0755 -d /etc/apt/keyrings`
`curl -fsSL https://download.docker.com/linux/ubuntu/gpg \| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg`
**3. Add Repository:**
`echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null`
**4. Install Docker:**
`sudo apt update && sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin` | `sudo systemctl enable docker --now` | +| **Arch/Manjaro** | **1. Install Package:**
`sudo pacman -Syu docker`
**2. Start Service:**
`sudo systemctl start docker.service` | `sudo systemctl status docker.service` | +| **Fedora/RHEL** | **1. Install Package:**
`sudo dnf -y install docker-ce docker-ce-cli containerd.io --repo docker-ce --repo docker-ce-stable`
**2. Start Service:**
`sudo systemctl start docker` | `sudo systemctl enable docker --now` | +| **macOS** | **Install Docker Desktop (Recommended):** Download the official `.dmg` installer from the Docker website. | Start the application. CLI tools (`docker`) are installed automatically. | +| **Windows** | **Install Docker Desktop (Recommended):** Download the official installer from the Docker website.
**Prerequisite:** Ensure **WSL 2** is installed and enabled for optimal performance. | Start the application. Verify in PowerShell: `docker info` | + +----- + +### Clarification on Windows Installation + +**WSL 2 and Windows:** Docker Desktop for Windows uses a lightweight Linux virtual machine (VM) powered by **WSL 2** by default. This VM hosts the Docker Engine. Therefore, while you install the *Desktop app*, you must have **WSL 2 enabled** for the container engine to function correctly and offer the best performance. + +----- + +## Usage Guide + +### 1\. Add the Node + +Find **"Docker Sandbox Runner"** in the **Advanced/Scripting** menu. + +### 2\. Define Inputs + +1. **Code (`code`):** Insert your Python script. The desired outputs must be assigned to variables `r1` through `r6`. +2. **Dynamic Inputs:** Right-click the node and use **"+ Add Input (Dynamic)"** to inject variables (e.g., `my_int (INT)`). + +### 3\. Example Code + +```python +# Access the injected variable "my_value" from the input slot +# Libraries like math, random, json, re, time are pre-loaded. + +import math + +if my_value > 50: + r1 = "Processed successfully." + r2 = my_value * math.pi +else: + r1 = "Value too low." + r2 = None +``` + +### 4\. Resource Management + +The persistent container `comfyui-sandbox-worker` will run until explicitly stopped. + + * **To Stop Consumption:** Stop the container manually via the Docker Desktop GUI or by CLI: + ```bash + docker stop comfyui-sandbox-worker + ``` + +----- + +## License + +This project is licensed under the **MIT License**. + +``` +MIT License + +Copyright (c) 2025 [Your Name or Project Name] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..f481959 --- /dev/null +++ b/__init__.py @@ -0,0 +1,20 @@ +# __init__.py +from .sandbox_node import DockerSandboxRunner + +# Mapeia a string usada no JSON do workflow para a classe Python +NODE_CLASS_MAPPINGS = { + "DockerSandboxRunner": DockerSandboxRunner +} + +# Mapeia o nome da classe para o nome de exibição no menu do ComfyUI +NODE_DISPLAY_NAME_MAPPINGS = { + "DockerSandboxRunner": "Docker Sandbox Runner" +} + +# ESTE CAMPO É CRÍTICO para o carregamento do JavaScript! +WEB_DIRECTORY = "./web" + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] + +# No final do arquivo __init__.py +print("✅ CUSTOM NODE DOCKER SANDBOX REGISTRADO COM SUCESSO.") \ No newline at end of file diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..f156d15 Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/__pycache__/sandbox_node.cpython-312.pyc b/__pycache__/sandbox_node.cpython-312.pyc new file mode 100644 index 0000000..56040c3 Binary files /dev/null and b/__pycache__/sandbox_node.cpython-312.pyc differ diff --git a/example_workflow.json b/example_workflow.json new file mode 100755 index 0000000..23ce23d --- /dev/null +++ b/example_workflow.json @@ -0,0 +1,348 @@ +{ + "id": "f32fcb48-b15c-444d-93c9-1e4ecf3d9806", + "revision": 0, + "last_node_id": 9, + "last_link_id": 7, + "nodes": [ + { + "id": 6, + "type": "PreviewAny", + "pos": [ + 985, + 215 + ], + "size": [ + 210, + 76 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 4 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.76", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "color": "#4a7b7b", + "bgcolor": "#335555", + "shape": 1 + }, + { + "id": 8, + "type": "PreviewAny", + "pos": [ + 985, + 499 + ], + "size": [ + 210, + 165 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 6 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.76", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "color": "#4a7b7b", + "bgcolor": "#335555", + "shape": 1 + }, + { + "id": 4, + "type": "DockerSandboxRunner", + "pos": [ + 555, + 215 + ], + "size": [ + 400, + 256 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "code", + "type": "STRING", + "widget": { + "name": "code" + }, + "link": 2 + }, + { + "name": "my_value (INT)", + "type": "INT", + "link": 3 + } + ], + "outputs": [ + { + "name": "r1", + "type": "*", + "links": [ + 4 + ] + }, + { + "name": "r2", + "type": "*", + "links": [ + 7 + ] + }, + { + "name": "r3", + "type": "*", + "links": null + }, + { + "name": "r4", + "type": "*", + "links": null + }, + { + "name": "r5", + "type": "*", + "links": null + }, + { + "name": "r6", + "type": "*", + "links": null + }, + { + "name": "log", + "type": "STRING", + "links": [ + 6 + ] + } + ], + "properties": { + "Node name for S&R": "DockerSandboxRunner", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5.2" + } + }, + "widgets_values": [ + "", + 10, + "512m" + ], + "color": "#4a7b7b", + "bgcolor": "#335555", + "shape": 1 + }, + { + "id": 9, + "type": "PreviewAny", + "pos": [ + 985, + 351 + ], + "size": [ + 210, + 88 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 7 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.76", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "color": "#4a7b7b", + "bgcolor": "#335555", + "shape": 1 + }, + { + "id": 5, + "type": "PrimitiveInt", + "pos": [ + 105, + 215 + ], + "size": [ + 420, + 82 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "INT", + "type": "INT", + "links": [ + 3 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.76", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "Node name for S&R": "PrimitiveInt" + }, + "widgets_values": [ + 60, + "fixed" + ], + "color": "#4a7b7b", + "bgcolor": "#335555", + "shape": 1 + }, + { + "id": 2, + "type": "PrimitiveStringMultiline", + "pos": [ + 105, + 357 + ], + "size": [ + 420, + 255 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [ + 2 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.76", + "Node name for S&R": "PrimitiveStringMultiline", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5.2" + } + }, + "widgets_values": [ + "# Access the injected variable \"my_value\" from the input slot\n# Libraries like math, random, json, re, time are pre-loaded.\n\nimport math \n\nif my_value > 50:\n r1 = \"Processed successfully.\"\n r2 = my_value * math.pi\nelse:\n r1 = \"Value too low.\"\n r2 = None\n\n\nprint (\"✅✅✅ Finished ✅✅✅\")" + ], + "color": "#4a7b7b", + "bgcolor": "#335555", + "shape": 1 + } + ], + "links": [ + [ + 2, + 2, + 0, + 4, + 0, + "STRING" + ], + [ + 3, + 5, + 0, + 4, + 1, + "INT" + ], + [ + 4, + 4, + 0, + 6, + 0, + "*" + ], + [ + 6, + 4, + 6, + 8, + 0, + "*" + ], + [ + 7, + 4, + 1, + 9, + 0, + "*" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ue_links": [], + "ds": { + "scale": 1.021848739495798, + "offset": [ + 103.33333333333337, + -34.98958333333326 + ] + }, + "links_added_by_ue": [], + "frontendVersion": "1.33.10", + "VHS_latentpreview": false, + "VHS_latentpreviewrate": 0, + "VHS_MetadataImage": true, + "VHS_KeepIntermediate": true + }, + "version": 0.4 +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bdb9670 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +docker diff --git a/sandbox_node.py b/sandbox_node.py new file mode 100644 index 0000000..97e8653 --- /dev/null +++ b/sandbox_node.py @@ -0,0 +1,188 @@ +import docker +import json +import re +import uuid +import base64 +import time + +class AnyType(str): + def __ne__(self, __value: object) -> bool: + return False + def __eq__(self, __value: object) -> bool: + return True + +# CLASSE RENOMEADA +class DockerSandboxRunner: + CONTAINER_NAME = "comfyui-sandbox-worker" + DOCKER_IMAGE = "python:3.9-slim" + SANDBOX_USER = "nobody" + + def __init__(self): + self.client = None + try: + self.client = docker.from_env() + except Exception: + print("⚠️ AVISO: Não foi possível conectar ao Docker. Verifique se o Docker Desktop está rodando.") + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "code": ("STRING", {"multiline": True, "placeholder": "# Código limpo, focado apenas na lógica de execução."}), + # CAMPOS OCULTOS PARA CONFIGURAÇÃO TÉCNICA + "timeout": ("INT", {"default": 10, "min": 1, "max": 60, "step": 1, "hidden": True}), + "memory_limit": ("STRING", {"default": "512m", "placeholder": "Ex: 512m, 1g, 2gb", "hidden": True}), + }, + "optional": { + # Campo de ação removido (como solicitado anteriormente). + } + } + + RETURN_TYPES = ( + AnyType("*"), AnyType("*"), AnyType("*"), AnyType("*"), AnyType("*"), AnyType("*"), + "STRING" + ) + RETURN_NAMES = ("r1", "r2", "r3", "r4", "r5", "r6", "log") + FUNCTION = "execute_sandbox" + CATEGORY = "Advanced/Scripting" + + def _ensure_container_running(self, mem_limit): + """Garante que o container worker está vivo e esperando comandos""" + if not self.client: + raise RuntimeError("Docker Client não inicializado. O Docker Desktop está rodando?") + + try: + container = self.client.containers.get(self.CONTAINER_NAME) + if container.status != 'running': + print(f"🛠️ Docker Sandbox: Iniciando container: {self.CONTAINER_NAME}") + container.start() + return container + except docker.errors.NotFound: + print(f"🛠️ Criando container sandbox: {self.CONTAINER_NAME} com {mem_limit} de RAM...") + return self.client.containers.run( + self.DOCKER_IMAGE, + command="tail -f /dev/null", + name=self.CONTAINER_NAME, + detach=True, + mem_limit=mem_limit, + network_mode="none", + user=self.SANDBOX_USER + ) + + def execute_sandbox(self, code, timeout, memory_limit="512m", **kwargs): + log_messages = [] + + final_results = [None] * 6 + object_references = {} + variable_injection_commands = [] + + try: + container = self._ensure_container_running(memory_limit) + except Exception as e: + log_messages.append(f"Docker Error (Startup/Connect): {str(e)}") + return (*final_results, "\n".join(log_messages)) + + # 1. Prepara Variáveis (usando Literal Python) + for key, value in kwargs.items(): + clean_key = key.split(" (")[0].strip().replace(" ", "_") + + if value is None or isinstance(value, bool): + variable_injection_commands.append(f"{clean_key} = {str(value)}") + elif isinstance(value, (int, float, str)): + variable_injection_commands.append(f"{clean_key} = {repr(value)}") + elif isinstance(value, (list, dict)): + try: + variable_injection_commands.append(f"{clean_key} = {repr(value)}") + except: + ref_id = f"__REF__{uuid.uuid4().hex}" + object_references[ref_id] = value + variable_injection_commands.append(f"{clean_key} = '{ref_id}'") + else: + ref_id = f"__REF__{uuid.uuid4().hex}" + object_references[ref_id] = value + variable_injection_commands.append(f"{clean_key} = '{ref_id}'") + + # 2. Constrói o Payload Final (Base64) + injection_script = "\n".join(variable_injection_commands) + + payload_script = f""" +import json +import sys +import math, random, time, re +import signal + +TIMEOUT_SECONDS = {timeout} + +def signal_handler(signum, frame): + print(f"__ERROR_START__TimeoutError: Script execution exceeded {{TIMEOUT_SECONDS}} seconds.__ERROR_END__") + sys.exit(1) + +signal.signal(signal.SIGALRM, signal_handler) +signal.alarm(TIMEOUT_SECONDS) + +{injection_script} + +_user_error = None +try: + exec({repr(code)}) +finally: + signal.alarm(0) + +outputs = {{}} +if _user_error: + outputs['error'] = _user_error +else: + for i in range(1, 7): + vname = f"r{{i}}" + if vname in locals(): + val = locals()[vname] + try: + json.dumps(val) + outputs[vname] = val + except: + outputs[vname] = str(val) + else: + outputs[vname] = None + +sys.stdout.write("__COMFY_RESULT_START__" + json.dumps(outputs) + "__COMFY_RESULT_END__") +""" + + b64_payload = base64.b64encode(payload_script.encode('utf-8')).decode('utf-8') + cmd = f"python3 -c \"import base64; exec(base64.b64decode('{b64_payload}'))\"" + + try: + exec_result = container.exec_run( + cmd=cmd, + workdir="/tmp" + ) + output_text = exec_result.output.decode('utf-8', errors='ignore') + + err_match = re.search(r"__ERROR_START__(.*?)__ERROR_END__", output_text, re.DOTALL) + if err_match: + log_messages.append(f"Script Error (in Docker):\n{err_match.group(1)}") + return (*final_results, "\n".join(log_messages) + "\n" + output_text) + + match = re.search(r"__COMFY_RESULT_START__(.*?)__COMFY_RESULT_END__", output_text, re.DOTALL) + if match: + json_str = match.group(1) + extracted_dict = json.loads(json_str) + + if 'error' in extracted_dict: + log_messages.append(f"Runtime Error:\n{extracted_dict['error']}") + return (*final_results, "\n".join(log_messages) + "\n" + output_text) + + for i in range(6): + key = f"r{i+1}" + val = extracted_dict.get(key) + + if isinstance(val, str) and val in object_references: + final_results[i] = object_references[val] + else: + final_results[i] = val + + log_messages.append("SUCCESS: Script executado com sucesso.") + return (*final_results, "\n".join(log_messages) + "\n" + output_text) + + except Exception as e: + log_messages.append(f"Execution Infrastructure Error (Docker exec failed): {str(e)}") + return (*final_results, "\n".join(log_messages)) \ No newline at end of file diff --git a/web/.DS_Store b/web/.DS_Store new file mode 100755 index 0000000..237ef2a Binary files /dev/null and b/web/.DS_Store differ diff --git a/web/._.DS_Store b/web/._.DS_Store new file mode 100755 index 0000000..28c42fb Binary files /dev/null and b/web/._.DS_Store differ diff --git a/web/js/._sandbox.js b/web/js/._sandbox.js new file mode 100755 index 0000000..789a914 Binary files /dev/null and b/web/js/._sandbox.js differ diff --git a/web/js/sandbox.js b/web/js/sandbox.js new file mode 100644 index 0000000..dfc05fa --- /dev/null +++ b/web/js/sandbox.js @@ -0,0 +1,79 @@ +import { app } from "/scripts/app.js"; + +app.registerExtension({ + name: "DockerSandbox.Extensions", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + // CORRIGIDO: Checa pelo nome da classe Python (sem espaços) + if (nodeData.name === "DockerSandboxRunner") { + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; + this.setSize([350, 150]); + return r; + }; + + // --- FUNÇÃO PARA INJETAR OPÇÕES DE MENU --- + const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function (_, options) { + const me = this; + + // 1. ADD INPUT + options.push({ + content: "+ Add Input (Dynamic)", + callback: () => { + const name = prompt("Enter variable name (e.g., width, my_text):"); + if (!name) return; + + const validTypes = ["STRING", "INT", "FLOAT", "BOOLEAN", "*"]; + let type = prompt( + `Enter type exactly as shown:\n\n${validTypes.join("\n")}\n\n(* = Any/Wildcard)`, + "*" + ); + + if (!type) return; + type = type.toUpperCase(); + + if (!validTypes.includes(type)) { + alert(`Invalid type! Please use one of: ${validTypes.join(", ")}`); + return; + } + + const finalName = `${name} (${type})`; + const exists = me.findInputSlot(finalName); + + if (exists !== -1) { + alert("An input with this name already exists."); + return; + } + + me.addInput(finalName, type); + app.graph.setDirtyCanvas(true, true); + } + }); + + // 2. REMOVE UNUSED INPUTS + options.push({ + content: "- Remove Unused Inputs", + callback: () => { + for (let i = me.inputs.length - 1; i >= 0; i--) { + const input = me.inputs[i]; + // Ignora os inputs fixos (code, timeout, memory_limit) + if (["code", "timeout", "memory_limit"].includes(input.name)) continue; + + // Remove se não estiver conectado + if (!input.link) { + me.removeInput(i); + } + } + app.graph.setDirtyCanvas(true, true); + } + }); + + if (getExtraMenuOptions) { + return getExtraMenuOptions.apply(this, arguments); + } + }; + } + } +}); \ No newline at end of file