diff --git a/.gitignore b/.gitignore
index 7cf569d6ff..86bf942491 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,19 @@ dist/
# Local Netlify folder
.netlify
src/emulator/release/
+
+# ======================================================================
+# vscode
+# ======================================================================
+# vscode configuration
+.vscode/
+
+# https://code.visualstudio.com/docs/languages/jsconfig
+jsconfig.json
+
+# ======================================================================
+# node js
+# ======================================================================
+# the exact tree installed in the node_modules folder
+package-lock.json
+
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000000..0e796c3f18
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,3 @@
+node_modules
+dist
+build
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000000..74612f8818
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,7 @@
+{
+ "singleQuote": true,
+ "semi": false,
+ "tabWidth": 2,
+ "trailingComma": "all",
+ "printWidth": 100
+}
diff --git a/README.md b/README.md
index 63f7f71adc..c0e27510ff 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,7 @@ cd puter
npm install
npm start
```
-✨ This should launch Puter at
+**→** This should launch Puter at
http://puter.localhost:4100 (or the next available port).
@@ -62,7 +62,7 @@ troubleshooting steps.
```bash
mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter
```
-✨ This should launch Puter at
+**→** This should launch Puter at
http://puter.localhost:4100 (or the next available port).
@@ -77,7 +77,7 @@ sudo chown -R 1000:1000 puter
wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml
docker compose up
```
-✨ This should be available at
+**→** This should be available at
http://puter.localhost:4100 (or the next available port).
@@ -92,7 +92,7 @@ New-Item -Path "puter\data" -ItemType Directory -Force
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml"
docker compose up
```
-✨ This should launch Puter at
+**→** This should launch Puter at
http://puter.localhost:4100 (or the next available port).
diff --git a/doc/i18n/README.es.md b/doc/i18n/README.es.md
index b56bec9ee7..365a5b59e1 100644
--- a/doc/i18n/README.es.md
+++ b/doc/i18n/README.es.md
@@ -2,22 +2,23 @@
El Sistema Operativo de Internet! Gratis, de Código abierto, y Autohospedable.
-
-
-
« DEMO EN VIVO »
Puter.com
·
- SDK
+ App Store
+ ·
+ Developers
+ ·
+ CLI
·
Discord
·
Reddit
·
- X (Twitter)
+ X (Twitter)
@@ -48,8 +49,9 @@ npm install
npm start
```
-Esto ejecutará Puter en http://puter.localhost:4100 (o el siguiente puerto disponible).
+✨ Esto ejecutará Puter en http://puter.localhost:4100 (o el siguiente puerto disponible).
+Si esto no funciona, consulta [First Run Issues](./doc/self-hosters/first-run-issues.md) para obtener pasos de solución de problemas.
### 🐳 Docker
@@ -58,6 +60,7 @@ Esto ejecutará Puter en http://puter.localhost:4100 (o el siguiente puerto disp
```bash
mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter
```
+✨ Esto ejecutará Puter en http://puter.localhost:4100 (o el siguiente puerto disponible).
@@ -72,6 +75,7 @@ sudo chown -R 1000:1000 puter
wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml
docker compose up
```
+✨ Esto ejecutará Puter en http://puter.localhost:4100 (o el siguiente puerto disponible).
#### Windows
@@ -85,8 +89,14 @@ New-Item -Path "puter\data" -ItemType Directory -Force
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml"
docker compose up
```
+✨ Esto ejecutará Puter en http://puter.localhost:4100 (o el siguiente puerto disponible).
+
+### 🚀 Auto-Hospedaje
+
+Para guías detalladas sobre cómo auto-hospedar Puter, incluyendo opciones de configuración y mejores prácticas, consulta nuestra [Documentación de Auto-Hospedaje](https://github.com/HeyPuter/puter/blob/main/doc/self-hosters/instructions.md).
+
### ☁️ Puter.com
Puter está disponible como servicio alojado en [**puter.com**](https://puter.com).
@@ -98,7 +108,7 @@ Puter está disponible como servicio alojado en [**puter.com**](https://puter.co
- **Sistemas operativos:** Linux, macOS, Windows
- **RAM:** 2GB mínimo (4GB recomendados)
- **Almacenamiento:** 1GB de espacio libre
-- **Node.js:** Versión 16+ (Versión 22+ recomendada)
+- **Node.js:** Versión 16+ (Versión 23+ recomendada)
- **npm:** Última version estable
@@ -125,3 +135,37 @@ Estamos siempre felices de ayudar con cualquier pregunta que puedas tener. No du
Este repositorio, incluyendo todo su contenido, sub-proyectos, modulos y componentes, esta licenciado bajo [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) a menos que se indique explícitamente lo contrario. Librerías de terceros incluidos en este repositorio pueden estar sujetas a sus propias licencias.
+
+## Traducciones
+
+- [Arabic / العربية](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ar.md)
+- [Armenian / Հայերեն](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hy.md)
+- [Bengali / বাংলা](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.bn.md)
+- [Chinese / 中文](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.zh.md)
+- [Danish / Dansk](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.da.md)
+- [English](https://github.com/HeyPuter/puter/blob/main/README.md)
+- [Farsi / فارسی](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fa.md)
+- [Finnish / Suomi](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fi.md)
+- [French / Français](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fr.md)
+- [German/ Deutsch](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.de.md)
+- [Hebrew/ עברית](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.he.md)
+- [Hindi / हिंदी](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hi.md)
+- [Hungarian / Magyar](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hu.md)
+- [Indonesian / Bahasa Indonesia](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.id.md)
+- [Italian / Italiano](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.it.md)
+- [Japanese / 日本語](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.jp.md)
+- [Korean / 한국어](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ko.md)
+- [Malayalam / മലയാളം](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ml.md)
+- [Polish / Polski](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pl.md)
+- [Portuguese / Português](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pt.md)
+- [Romanian / Română](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ro.md)
+- [Russian / Русский](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ru.md)
+- [Spanish / Español](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.es.md)
+- [Swedish / Svenska](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.sv.md)
+- [Tamil / தமிழ்](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ta.md)
+- [Telugu / తెలుగు](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.te.md)
+- [Thai / ไทย](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.th.md)
+- [Turkish / Türkçe](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.tr.md)
+- [Ukrainian / Українська](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ua.md)
+- [Urdu / اردو](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ur.md)
+- [Vietnamese / Tiếng Việt](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.vi.md)
\ No newline at end of file
diff --git a/doc/i18n/README.pt.md b/doc/i18n/README.pt.md
index 98813b0253..927dd0a3f0 100644
--- a/doc/i18n/README.pt.md
+++ b/doc/i18n/README.pt.md
@@ -2,22 +2,23 @@
O Sistema Operacional da Internet! Gratuito, de Código Aberto e Auto-Hospedável.
-
-
-
« DEMONSTRAÇÃO AO VIVO »
Puter.com
·
- SDK
+ App Store
+ ·
+ Developers
+ ·
+ CLI
·
Discord
·
Reddit
·
- X (Twitter)
+ X (Twitter)
@@ -40,15 +41,17 @@ Puter é um sistema operacional de internet avançado e de código aberto, proje
### 💻 Desenvolvimento Local
-
-```bash
+```
git clone https://github.com/HeyPuter/puter
cd puter
npm install
npm start
```
-Isso iniciará o Puter em http://puter.localhost:4100 (ou na próxima porta disponível).
+✨ Isso iniciará o Puter em http://puter.localhost:4100 (ou na próxima porta disponível).
+
+
+Se isso não funcionar, consulte [First Run Issues](./doc/self-hosters/first-run-issues.md) para solucionar os problemas.
@@ -58,6 +61,7 @@ Isso iniciará o Puter em http://puter.localhost:4100 (ou na próxima porta disp
```bash
mkdir puter && cd puter && mkdir -p puter/config puter/data && sudo chown -R 1000:1000 puter && docker run --rm -p 4100:4100 -v `pwd`/puter/config:/etc/puter -v `pwd`/puter/data:/var/puter ghcr.io/heyputer/puter
```
+✨ Isso iniciará o Puter em http://puter.localhost:4100 (ou na próxima porta disponível).
@@ -72,6 +76,8 @@ sudo chown -R 1000:1000 puter
wget https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml
docker compose up
```
+✨ Isso iniciará o Puter em http://puter.localhost:4100 (ou na próxima porta disponível).
+
#### Windows
@@ -85,8 +91,14 @@ New-Item -Path "puter\data" -ItemType Directory -Force
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/HeyPuter/puter/main/docker-compose.yml" -OutFile "docker-compose.yml"
docker compose up
```
+✨ Isso iniciará o Puter em http://puter.localhost:4100 (ou na próxima porta disponível).
+
+### 🚀 Auto-Hospedagem
+
+Para guia detalhados sobre como auto-hospedar o Puter, incluindo opções de configuração e melhores práticas, consulte nossa [Documentação de Auto-Hospedagem](https://github.com/HeyPuter/puter/blob/main/doc/self-hosters/instructions.md).
+
### ☁️ Puter.com
O Puter está disponível como um serviço hospedado em [**puter.com**](https://puter.com).
@@ -98,7 +110,7 @@ O Puter está disponível como um serviço hospedado em [**puter.com**](https://
- **Sistema operacional:** Linux, macOS, Windows
- **RAM:** 2GB mínimo (4GB recomendado)
- **Espaço de disco:** 1GB de espaço disponível
-- **Node.js:** Versão 16+ (Versão 22+ recomendada)
+- **Node.js:** Versão 16+ (Versão 23+ recomendada)
- **npm:** Última versão estável
@@ -125,3 +137,37 @@ Estamos sempre felizes em ajudá-lo com quaisquer perguntas que você possa ter.
Este repositório, incluindo todos os seus conteúdos, subprojetos, módulos e componentes, está licenciado sob [AGPL-3.0](https://github.com/HeyPuter/puter/blob/main/LICENSE.txt) a menos que explicitamente indicado de outra forma. Bibliotecas de terceiros incluídas neste repositório podem estar sujeitas às suas próprias licenças.
+
+## Traduções
+
+- [Arabic / العربية](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ar.md)
+- [Armenian / Հայերեն](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hy.md)
+- [Bengali / বাংলা](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.bn.md)
+- [Chinese / 中文](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.zh.md)
+- [Danish / Dansk](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.da.md)
+- [English](https://github.com/HeyPuter/puter/blob/main/README.md)
+- [Farsi / فارسی](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fa.md)
+- [Finnish / Suomi](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fi.md)
+- [French / Français](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.fr.md)
+- [German/ Deutsch](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.de.md)
+- [Hebrew/ עברית](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.he.md)
+- [Hindi / हिंदी](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hi.md)
+- [Hungarian / Magyar](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.hu.md)
+- [Indonesian / Bahasa Indonesia](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.id.md)
+- [Italian / Italiano](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.it.md)
+- [Japanese / 日本語](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.jp.md)
+- [Korean / 한국어](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ko.md)
+- [Malayalam / മലയാളം](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ml.md)
+- [Polish / Polski](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pl.md)
+- [Portuguese / Português](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pt.md)
+- [Romanian / Română](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ro.md)
+- [Russian / Русский](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ru.md)
+- [Spanish / Español](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.es.md)
+- [Swedish / Svenska](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.sv.md)
+- [Tamil / தமிழ்](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ta.md)
+- [Telugu / తెలుగు](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.te.md)
+- [Thai / ไทย](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.th.md)
+- [Turkish / Türkçe](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.tr.md)
+- [Ukrainian / Українська](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ua.md)
+- [Urdu / اردو](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ur.md)
+- [Vietnamese / Tiếng Việt](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.vi.md)
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 5674e8574b..ade3163de4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2679,6 +2679,14 @@
"@hapi/topo": "^5.0.0"
}
},
+ "node_modules/@heyputer/airouter": {
+ "resolved": "src/airouter.js",
+ "link": true
+ },
+ "node_modules/@heyputer/airouter.js": {
+ "resolved": "src/airouter.js",
+ "link": true
+ },
"node_modules/@heyputer/backend": {
"resolved": "src/backend",
"link": true
@@ -7418,15 +7426,16 @@
}
},
"node_modules/@types/request/node_modules/form-data": {
- "version": "2.5.3",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.3.tgz",
- "integrity": "sha512-XHIrMD0NpDrNM/Ckf7XJiBbLl57KEhT3+i3yY+eWm+cqYZJQTZrKo8Y8AWKnuV5GT4scfuUGt9LzNoIx3dU1nQ==",
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz",
+ "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==",
"license": "MIT",
"optional": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
"mime-types": "^2.1.35",
"safe-buffer": "^5.2.1"
},
@@ -8470,13 +8479,6 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
- "node_modules/body-parser/node_modules/bytes": {
- "version": "3.1.2",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
"node_modules/body-parser/node_modules/debug": {
"version": "2.6.9",
"license": "MIT",
@@ -8620,7 +8622,9 @@
}
},
"node_modules/bytes": {
- "version": "3.0.0",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -9045,15 +9049,17 @@
}
},
"node_modules/compression": {
- "version": "1.7.4",
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
+ "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
- "accepts": "~1.3.5",
- "bytes": "3.0.0",
- "compressible": "~2.0.16",
+ "bytes": "3.1.2",
+ "compressible": "~2.0.18",
"debug": "2.6.9",
- "on-headers": "~1.0.2",
- "safe-buffer": "5.1.2",
+ "negotiator": "~0.6.4",
+ "on-headers": "~1.1.0",
+ "safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
@@ -9071,6 +9077,35 @@
"version": "2.0.0",
"license": "MIT"
},
+ "node_modules/compression/node_modules/negotiator": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
+ "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/compression/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"license": "MIT"
@@ -10779,12 +10814,15 @@
}
},
"node_modules/form-data": {
- "version": "4.0.2",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+ "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
@@ -13364,14 +13402,16 @@
}
},
"node_modules/morgan": {
- "version": "1.10.0",
+ "version": "1.10.1",
+ "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
+ "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
"license": "MIT",
"dependencies": {
"basic-auth": "~2.0.1",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-finished": "~2.3.0",
- "on-headers": "~1.0.2"
+ "on-headers": "~1.1.0"
},
"engines": {
"node": ">= 0.8.0"
@@ -13869,7 +13909,9 @@
}
},
"node_modules/on-headers": {
- "version": "1.0.2",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -14704,13 +14746,6 @@
"node": ">= 0.8"
}
},
- "node_modules/raw-body/node_modules/bytes": {
- "version": "3.1.2",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
"node_modules/rc": {
"version": "1.2.8",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
@@ -14973,23 +15008,18 @@
}
},
"node_modules/response-time": {
- "version": "2.3.2",
+ "version": "2.3.4",
+ "resolved": "https://registry.npmjs.org/response-time/-/response-time-2.3.4.tgz",
+ "integrity": "sha512-fiyq1RvW5/Br6iAtT8jN1XrNY8WPu2+yEypLbaijWry8WDZmn12azG9p/+c+qpEebURLlQmqCB8BNSu7ji+xQQ==",
"license": "MIT",
"dependencies": {
- "depd": "~1.1.0",
- "on-headers": "~1.0.1"
+ "depd": "~2.0.0",
+ "on-headers": "~1.1.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
- "node_modules/response-time/node_modules/depd": {
- "version": "1.1.2",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@@ -17579,6 +17609,11 @@
"url": "https://github.com/sponsors/colinhacks"
}
},
+ "src/airouter.js": {
+ "name": "@heyputer/airouter.js",
+ "version": "0.0.0",
+ "license": "UNLICENSED"
+ },
"src/backend": {
"name": "@heyputer/backend",
"version": "2.5.1",
@@ -17588,6 +17623,7 @@
"@aws-sdk/client-polly": "^3.622.0",
"@aws-sdk/client-textract": "^3.621.0",
"@google/generative-ai": "^0.21.0",
+ "@heyputer/airouter": "^0.0.0",
"@heyputer/kv.js": "^0.1.9",
"@heyputer/multest": "^0.0.2",
"@heyputer/putility": "^1.0.0",
diff --git a/src/airouter.js/airouter.js b/src/airouter.js/airouter.js
new file mode 100644
index 0000000000..fd0c460a80
--- /dev/null
+++ b/src/airouter.js/airouter.js
@@ -0,0 +1,20 @@
+// Streaming Utilities
+export { CompletionWriter } from './common/stream/CompletionWriter.js';
+export { MessageWriter } from './common/stream/MessageWriter.js';
+export { ToolUseWriter } from './common/stream/ToolUseWriter.js';
+export { TextWriter } from './common/stream/TextWriter.js';
+export { BaseWriter } from './common/stream/BaseWriter.js';
+
+// Common prompt processing
+export { UniversalPromptNormalizer } from './common/prompt/UniversalPromptNormalizer.js';
+export { NormalizedPromptUtil } from './common/prompt/NormalizedPromptUtil.js';
+export { UniversalToolsNormalizer } from './common/prompt/UniversalToolsNormalizer.js';
+
+// Model-Specific Processing
+export { AnthropicToolsAdapter } from './anthropic/AnthropicToolsAdapter.js';
+export { OpenAIToolsAdapter } from './openai/OpenAIToolsAdapter.js';
+export { GeminiToolsAdapter } from './gemini/GeminiToolsAdapter.js';
+
+// Model-Specific Output Adaptation
+export { AnthropicStreamAdapter } from './anthropic/AnthropicStreamAdapter.js';
+export { AnthropicAPIType } from './anthropic/AnthropicAPIType.js';
diff --git a/src/airouter.js/anthropic/AnthropicAPIType.js b/src/airouter.js/anthropic/AnthropicAPIType.js
new file mode 100644
index 0000000000..5b9ea3f038
--- /dev/null
+++ b/src/airouter.js/anthropic/AnthropicAPIType.js
@@ -0,0 +1,190 @@
+import { AnthropicStreamAdapter } from "./AnthropicStreamAdapter.js";
+import { AnthropicToolsAdapter } from "./AnthropicToolsAdapter.js";
+import { NormalizedPromptUtil } from "../common/prompt/NormalizedPromptUtil.js";
+import { PassThrough } from 'node:stream';
+
+const FILES_API_BETA_STRING = 'files-api-2025-04-14';
+
+export class AnthropicAPIStreamInvocation {
+ /**
+ *
+ * @param {Object} params
+ * @param {import('@anthropic-ai/sdk').Anthropic} params.client
+ * @param {import('../common/stream/CompletionWriter').CompletionWriter} params.completionWriter
+ */
+ constructor ({ client, sdk_params, usageWriter, completionWriter, cleanups }) {
+ this.client = client;
+ this.sdk_params = sdk_params;
+ this.usageWriter = usageWriter;
+ this.completionWriter = completionWriter;
+ this.cleanups = cleanups ?? [];
+ }
+ async run () {
+ const anthropicStream = await this.client.messages.stream(this.sdk_params);
+ await AnthropicStreamAdapter.write_to_stream({
+ input: anthropicStream,
+ completionWriter: this.completionWriter,
+ usageWriter: this.usageWriter,
+ });
+ }
+ async cleanup () {
+ await Promise.all(this.cleanups);
+ }
+}
+
+export class AnthropicAPISyncInvocation {
+ /**
+ *
+ * @param {Object} params
+ * @param {import('@anthropic-ai/sdk').Anthropic} params.client
+ * @param {import('../common/stream/CompletionWriter').CompletionWriter} params.completionWriter
+ */
+ constructor ({ client, sdk_params, cleanups }) {
+ this.client = client;
+ this.sdk_params = sdk_params;
+ this.cleanups = cleanups ?? [];
+ }
+ async run () {
+ const msg = await this.client.messages.create(this.sdk_params);
+ return {
+ message: msg,
+ usage: msg.usage,
+ finish_reason: 'stop',
+ };
+ }
+ async cleanup () {
+ await Promise.all(this.cleanups);
+ }
+}
+
+export class AnthropicAPIType {
+ /**
+ *
+ * @param {import('@anthropic-ai/sdk').Anthropic} client
+ * @param {import('../common/stream/CompletionWriter').CompletionWriter} completionWriter
+ * @param {*} options
+ */
+ async stream (client, completionWriter, options) {
+ const sdk_params = this.create_sdk_params_(options);
+
+ const cleanups = [];
+
+ const has_files = await this.handle_files_({ cleanups, messages: options.messages });
+ if ( has_files ) {
+ client = client.beta;
+ }
+
+ const usageWriter = options.usageWriter ?? { resolve: () => {} };
+
+ return new AnthropicAPIStreamInvocation({
+ client,
+ sdk_params,
+ usageWriter,
+ completionWriter,
+ cleanups,
+ });
+ }
+ async create (client, options) {
+ const sdk_params = this.create_sdk_params_(options);
+
+ const cleanups = [];
+
+ const has_files = await this.handle_files_({ cleanups, messages: options.messages });
+ if ( has_files ) {
+ client = client.beta;
+ }
+
+ return new AnthropicAPISyncInvocation({
+ client,
+ sdk_params,
+ cleanups,
+ });
+ }
+
+ create_sdk_params_ (options) {
+ options.tools = AnthropicToolsAdapter.adapt_tools(options.tools);
+
+ let system_prompts;
+ [system_prompts, options.messages] = NormalizedPromptUtil.extract_and_remove_system_messages(options.messages);
+
+ return {
+ model: options.model,
+ max_tokens: Math.floor(options.max_tokens) ||
+ ((
+ model === 'claude-3-5-sonnet-20241022'
+ || model === 'claude-3-5-sonnet-20240620'
+ ) ? 8192 : 4096), //required
+ temperature: options.temperature || 0, // required
+ ...(system_prompts ? {
+ system: system_prompts.length > 1
+ ? JSON.stringify(system_prompts)
+ : JSON.stringify(system_prompts[0])
+ } : {}),
+ messages: options.messages,
+ ...(options.tools ? { tools: options.tools } : {}),
+ };
+ }
+
+ async handle_files_ ({ cleanups, messages }) {
+ const file_input_tasks = [];
+ for ( const message of messages ) {
+ // We can assume `message.content` is not undefined because
+ // UniversalPromptNormalizer ensures this.
+ for ( const contentPart of message.content ) {
+ if ( contentPart.type !== 'data' ) continue;
+ const { data } = contentPart;
+ delete contentPart.data;
+ file_input_tasks.push({
+ data,
+ contentPart,
+ });
+ }
+ }
+
+ if ( file_input_tasks.length === 0 ) return false;
+
+ const promises = [];
+ for ( const task of file_input_tasks ) promises.push((async () => {
+ const stream = await task.data.getStream();
+ const mimeType = await task.data.getMimeType();
+
+ beta_mode = true;
+ const fileUpload = await this.anthropic.beta.files.upload({
+ file: await toFile(stream, undefined, { type: mimeType })
+ }, {
+ betas: [FILES_API_BETA_STRING]
+ });
+
+ cleanups.push(() => this.anthropic.beta.files.delete(
+ fileUpload.id,
+ { betas: [FILES_API_BETA_STRING] },
+ ));
+
+ // We have to copy a table from the documentation here:
+ // https://docs.anthropic.com/en/docs/build-with-claude/files
+ const contentBlockTypeForFileBasedOnMime = (() => {
+ if ( mimeType.startsWith('image/') ) {
+ return 'image';
+ }
+ if ( mimeType.startsWith('text/') ) {
+ return 'document';
+ }
+ if ( mimeType === 'application/pdf' || mimeType === 'application/x-pdf' ) {
+ return 'document';
+ }
+ return 'container_upload';
+ })();
+
+ delete task.contentPart.data,
+ task.contentPart.type = contentBlockTypeForFileBasedOnMime;
+ task.contentPart.source = {
+ type: 'file',
+ file_id: fileUpload.id,
+ };
+ })());
+
+ await Promise.all(promises);
+
+ return true;
+ }
+}
diff --git a/src/airouter.js/anthropic/AnthropicStreamAdapter.js b/src/airouter.js/anthropic/AnthropicStreamAdapter.js
new file mode 100644
index 0000000000..0f9c6e74ca
--- /dev/null
+++ b/src/airouter.js/anthropic/AnthropicStreamAdapter.js
@@ -0,0 +1,59 @@
+export class AnthropicStreamAdapter {
+ static async write_to_stream ({ input, completionWriter, usageWriter }) {
+ let message, contentBlock;
+ let counts = { input_tokens: 0, output_tokens: 0 };
+ for await ( const event of input ) {
+ const input_tokens =
+ (event?.usage ?? event?.message?.usage)?.input_tokens;
+ const output_tokens =
+ (event?.usage ?? event?.message?.usage)?.output_tokens;
+
+ if ( input_tokens ) counts.input_tokens += input_tokens;
+ if ( output_tokens ) counts.output_tokens += output_tokens;
+
+ if ( event.type === 'message_start' ) {
+ message = completionWriter.message();
+ continue;
+ }
+ if ( event.type === 'message_stop' ) {
+ message.end();
+ message = null;
+ continue;
+ }
+
+ if ( event.type === 'content_block_start' ) {
+ if ( event.content_block.type === 'tool_use' ) {
+ contentBlock = message.contentBlock({
+ type: event.content_block.type,
+ id: event.content_block.id,
+ name: event.content_block.name,
+ });
+ continue;
+ }
+ contentBlock = message.contentBlock({
+ type: event.content_block.type,
+ });
+ continue;
+ }
+
+ if ( event.type === 'content_block_stop' ) {
+ contentBlock.end();
+ contentBlock = null;
+ continue;
+ }
+
+ if ( event.type === 'content_block_delta' ) {
+ if ( event.delta.type === 'input_json_delta' ) {
+ contentBlock.addPartialJSON(event.delta.partial_json);
+ continue;
+ }
+ if ( event.delta.type === 'text_delta' ) {
+ contentBlock.addText(event.delta.text);
+ continue;
+ }
+ }
+ }
+ completionWriter.end();
+ usageWriter.resolve(counts);
+ }
+}
diff --git a/src/airouter.js/anthropic/AnthropicToolsAdapter.js b/src/airouter.js/anthropic/AnthropicToolsAdapter.js
new file mode 100644
index 0000000000..a0f1314763
--- /dev/null
+++ b/src/airouter.js/anthropic/AnthropicToolsAdapter.js
@@ -0,0 +1,13 @@
+export class AnthropicToolsAdapter {
+ static adapt_tools (tools) {
+ if ( ! tools ) return undefined;
+ return tools.map(tool => {
+ const { name, description, parameters } = tool.function;
+ return {
+ name,
+ description,
+ input_schema: parameters,
+ };
+ });
+ }
+}
diff --git a/src/airouter.js/common/prompt/NormalizedPromptUtil.js b/src/airouter.js/common/prompt/NormalizedPromptUtil.js
new file mode 100644
index 0000000000..53589399e6
--- /dev/null
+++ b/src/airouter.js/common/prompt/NormalizedPromptUtil.js
@@ -0,0 +1,47 @@
+import { whatis } from "../util/lang.js";
+
+/**
+ * NormalizedPromptUtil provides utility functions that can be called on
+ * normalized arrays of "chat" messages.
+ */
+export class NormalizedPromptUtil {
+ static extract_text (messages) {
+ return messages.map(m => {
+ if ( whatis(m) === 'string' ) {
+ return m;
+ }
+ if ( whatis(m) !== 'object' ) {
+ return '';
+ }
+ if ( whatis(m.content) === 'array' ) {
+ return m.content.map(c => c.text).join(' ');
+ }
+ if ( whatis(m.content) === 'string' ) {
+ return m.content;
+ } else {
+ const is_text_type = m.content.type === 'text' ||
+ ! m.content.hasOwnProperty('type');
+ if ( is_text_type ) {
+ if ( whatis(m.content.text) !== 'string' ) {
+ throw new Error('text content must be a string');
+ }
+ return m.content.text;
+ }
+ return '';
+ }
+ }).join(' ');
+ }
+
+ static extract_and_remove_system_messages (messages) {
+ let system_messages = [];
+ let new_messages = [];
+ for ( let i=0 ; i < messages.length ; i++ ) {
+ if ( messages[i].role === 'system' ) {
+ system_messages.push(messages[i]);
+ } else {
+ new_messages.push(messages[i]);
+ }
+ }
+ return [system_messages, new_messages];
+ }
+}
\ No newline at end of file
diff --git a/src/airouter.js/common/prompt/NormalizedPromptUtil.test.js b/src/airouter.js/common/prompt/NormalizedPromptUtil.test.js
new file mode 100644
index 0000000000..e13cfb3720
--- /dev/null
+++ b/src/airouter.js/common/prompt/NormalizedPromptUtil.test.js
@@ -0,0 +1,50 @@
+import { describe, it, expect } from 'vitest';
+const { NormalizedPromptUtil } = require('./NormalizedPromptUtil.js');
+
+describe('NormalizedPromptUtil', () => {
+ describe('extract_text', () => {
+ const cases = [
+ {
+ name: 'string message',
+ input: ['Hello, world!'],
+ output: 'Hello, world!',
+ },
+ {
+ name: 'object message',
+ input: [{
+ content: [
+ {
+ type: 'text',
+ text: 'Hello, world!',
+ }
+ ]
+ }],
+ output: 'Hello, world!',
+ },
+ {
+ name: 'irregular message',
+ input: [
+ 'First Part',
+ {
+ content: [
+ {
+ type: 'text',
+ text: 'Second Part',
+ }
+ ]
+ },
+ {
+ content: 'Third Part',
+ }
+ ],
+ output: 'First Part Second Part Third Part',
+ }
+ ];
+ for ( const tc of cases ) {
+ it(`should extract text from ${tc.name}`, () => {
+ const output = NormalizedPromptUtil.extract_text(tc.input);
+ expect(output).toBe(tc.output);
+ });
+ }
+ });
+});
diff --git a/src/backend/src/modules/puterai/lib/Messages.js b/src/airouter.js/common/prompt/UniversalPromptNormalizer.js
similarity index 73%
rename from src/backend/src/modules/puterai/lib/Messages.js
rename to src/airouter.js/common/prompt/UniversalPromptNormalizer.js
index bee8cc37ae..2bf83d79ef 100644
--- a/src/backend/src/modules/puterai/lib/Messages.js
+++ b/src/airouter.js/common/prompt/UniversalPromptNormalizer.js
@@ -1,6 +1,15 @@
-const { whatis } = require("../../../util/langutil");
+import { whatis } from "../util/lang.js";
-module.exports = class Messages {
+/**
+ * UniversalPromptNormalizer is a PromptNormalizer which consumes "prompts"
+ * (arrays of messages) in a format called "universal", and coerces them into
+ * a format called "normalized".
+ *
+ * In plain terms, this is the code responsible for taking the input JSON
+ * for AI chat and standardizing it into a format that the model-specific
+ * prompt adapters can understand.
+ */
+export class UniversalPromptNormalizer {
static normalize_single_message (message, params = {}) {
params = Object.assign({
role: 'user',
@@ -106,44 +115,4 @@ module.exports = class Messages {
return merged_messages;
}
-
- static extract_and_remove_system_messages (messages) {
- let system_messages = [];
- let new_messages = [];
- for ( let i=0 ; i < messages.length ; i++ ) {
- if ( messages[i].role === 'system' ) {
- system_messages.push(messages[i]);
- } else {
- new_messages.push(messages[i]);
- }
- }
- return [system_messages, new_messages];
- }
-
- static extract_text (messages) {
- return messages.map(m => {
- if ( whatis(m) === 'string' ) {
- return m;
- }
- if ( whatis(m) !== 'object' ) {
- return '';
- }
- if ( whatis(m.content) === 'array' ) {
- return m.content.map(c => c.text).join(' ');
- }
- if ( whatis(m.content) === 'string' ) {
- return m.content;
- } else {
- const is_text_type = m.content.type === 'text' ||
- ! m.content.hasOwnProperty('type');
- if ( is_text_type ) {
- if ( whatis(m.content.text) !== 'string' ) {
- throw new Error('text content must be a string');
- }
- return m.content.text;
- }
- return '';
- }
- }).join(' ');
- }
}
\ No newline at end of file
diff --git a/src/airouter.js/common/prompt/UniversalPromptNormalizer.test.js b/src/airouter.js/common/prompt/UniversalPromptNormalizer.test.js
new file mode 100644
index 0000000000..b74937b79d
--- /dev/null
+++ b/src/airouter.js/common/prompt/UniversalPromptNormalizer.test.js
@@ -0,0 +1,100 @@
+import { describe, it, expect } from 'vitest';
+import { UniversalPromptNormalizer } from './UniversalPromptNormalizer.js';
+
+describe('UniversalPromptUtil', () => {
+ describe('normalize_single_message', () => {
+ const cases = [
+ {
+ name: 'string message',
+ input: 'Hello, world!',
+ output: {
+ role: 'user',
+ content: [
+ {
+ type: 'text',
+ text: 'Hello, world!',
+ }
+ ]
+ }
+ }
+ ];
+ for ( const tc of cases ) {
+ it(`should normalize ${tc.name}`, () => {
+ const output = UniversalPromptNormalizer.normalize_single_message(tc.input);
+ expect(output).toEqual(tc.output);
+ });
+ }
+ });
+ describe('normalize OpenAI tool calls', () => {
+ const cases = [
+ {
+ name: 'string message',
+ input: {
+ role: 'assistant',
+ tool_calls: [
+ {
+ id: 'tool-1',
+ type: 'function',
+ function: {
+ name: 'tool-1-function',
+ arguments: {},
+ }
+ }
+ ]
+ },
+ output: {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool_use',
+ id: 'tool-1',
+ name: 'tool-1-function',
+ input: {},
+ }
+ ]
+ }
+ }
+ ];
+ for ( const tc of cases ) {
+ it(`should normalize ${tc.name}`, () => {
+ const output = UniversalPromptNormalizer.normalize_single_message(tc.input);
+ expect(output).toEqual(tc.output);
+ });
+ }
+ });
+ describe('normalize Claude tool calls', () => {
+ const cases = [
+ {
+ name: 'string message',
+ input: {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool_use',
+ id: 'tool-1',
+ name: 'tool-1-function',
+ input: "{}",
+ }
+ ]
+ },
+ output: {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool_use',
+ id: 'tool-1',
+ name: 'tool-1-function',
+ input: "{}",
+ }
+ ]
+ }
+ }
+ ];
+ for ( const tc of cases ) {
+ it(`should normalize ${tc.name}`, () => {
+ const output = UniversalPromptNormalizer.normalize_single_message(tc.input);
+ expect(output).toEqual(tc.output);
+ });
+ }
+ });
+});
\ No newline at end of file
diff --git a/src/backend/src/modules/puterai/lib/FunctionCalling.js b/src/airouter.js/common/prompt/UniversalToolsNormalizer.js
similarity index 98%
rename from src/backend/src/modules/puterai/lib/FunctionCalling.js
rename to src/airouter.js/common/prompt/UniversalToolsNormalizer.js
index 00f438aa71..24a525bbdc 100644
--- a/src/backend/src/modules/puterai/lib/FunctionCalling.js
+++ b/src/airouter.js/common/prompt/UniversalToolsNormalizer.js
@@ -1,4 +1,4 @@
-module.exports = class FunctionCalling {
+export class UniversalToolsNormalizer {
/**
* Normalizes the 'tools' object in-place.
*
diff --git a/src/airouter.js/common/stream/BaseWriter.js b/src/airouter.js/common/stream/BaseWriter.js
new file mode 100644
index 0000000000..640225941a
--- /dev/null
+++ b/src/airouter.js/common/stream/BaseWriter.js
@@ -0,0 +1,9 @@
+export class BaseWriter {
+ constructor (chatStream, params) {
+ this.chatStream = chatStream;
+ if ( this._start ) this._start(params);
+ }
+ end () {
+ if ( this._end ) this._end();
+ }
+}
diff --git a/src/airouter.js/common/stream/CompletionWriter.js b/src/airouter.js/common/stream/CompletionWriter.js
new file mode 100644
index 0000000000..d4e1b46169
--- /dev/null
+++ b/src/airouter.js/common/stream/CompletionWriter.js
@@ -0,0 +1,15 @@
+import { MessageWriter } from "./MessageWriter.js";
+
+export class CompletionWriter {
+ constructor ({ stream }) {
+ this.stream = stream;
+ }
+
+ end () {
+ this.stream.end();
+ }
+
+ message () {
+ return new MessageWriter(this);
+ }
+}
diff --git a/src/airouter.js/common/stream/MessageWriter.js b/src/airouter.js/common/stream/MessageWriter.js
new file mode 100644
index 0000000000..091b9f61cc
--- /dev/null
+++ b/src/airouter.js/common/stream/MessageWriter.js
@@ -0,0 +1,15 @@
+import { BaseWriter } from "./BaseWriter.js";
+import { TextWriter } from "./TextWriter.js";
+import { ToolUseWriter } from "./ToolUseWriter.js";
+
+export class MessageWriter extends BaseWriter {
+ contentBlock ({ type, ...params }) {
+ if ( type === 'tool_use' ) {
+ return new ToolUseWriter(this.chatStream, params);
+ }
+ if ( type === 'text' ) {
+ return new TextWriter(this.chatStream, params);
+ }
+ throw new Error(`Unknown content block type: ${type}`);
+ }
+}
diff --git a/src/airouter.js/common/stream/TextWriter.js b/src/airouter.js/common/stream/TextWriter.js
new file mode 100644
index 0000000000..6da2bdc548
--- /dev/null
+++ b/src/airouter.js/common/stream/TextWriter.js
@@ -0,0 +1,10 @@
+import { BaseWriter } from "./BaseWriter.js";
+
+export class TextWriter extends BaseWriter {
+ addText (text) {
+ const json = JSON.stringify({
+ type: 'text', text,
+ });
+ this.chatStream.stream.write(json + '\n');
+ }
+}
diff --git a/src/airouter.js/common/stream/ToolUseWriter.js b/src/airouter.js/common/stream/ToolUseWriter.js
new file mode 100644
index 0000000000..c5bbc8e6c9
--- /dev/null
+++ b/src/airouter.js/common/stream/ToolUseWriter.js
@@ -0,0 +1,45 @@
+import { BaseWriter } from "./BaseWriter.js";
+
+/**
+ * Assign the properties of the override object to the original object,
+ * like Object.assign, except properties are ordered so override properties
+ * are enumerated first.
+ *
+ * @param {*} original
+ * @param {*} override
+ */
+const objectAssignTop = (original, override) => {
+ let o = {
+ ...original,
+ ...override,
+ };
+ o = {
+ ...override,
+ ...original,
+ };
+ return o;
+}
+
+export class ToolUseWriter extends BaseWriter {
+ _start (params) {
+ this.contentBlock = params;
+ this.buffer = '';
+ }
+ addPartialJSON (partial_json) {
+ this.buffer += partial_json;
+ }
+ _end () {
+ if ( this.buffer.trim() === '' ) {
+ this.buffer = '{}';
+ }
+ if ( process.env.DEBUG ) console.log('BUFFER BEING PARSED', this.buffer);
+ const str = JSON.stringify(objectAssignTop({
+ ...this.contentBlock,
+ input: JSON.parse(this.buffer),
+ ...( ! this.contentBlock.text ? { text: "" } : {}),
+ }, {
+ type: 'tool_use',
+ }));
+ this.chatStream.stream.write(str + '\n');
+ }
+}
diff --git a/src/airouter.js/common/util/lang.js b/src/airouter.js/common/util/lang.js
new file mode 100644
index 0000000000..9e623f85c4
--- /dev/null
+++ b/src/airouter.js/common/util/lang.js
@@ -0,0 +1,24 @@
+// Utilities that cover language builtin shortcomings, and make
+// writing javascript code a little more convenient.
+
+/**
+ * whatis exists because checking types such as 'object' and 'array'
+ * can be done incorrectly very easily. This give sthe correct
+ * implementation a single source of truth.
+ * @param {*} thing
+ * @returns {string}
+ */
+export const whatis = thing => {
+ if ( Array.isArray(thing) ) return 'array';
+ if ( thing === null ) return 'null';
+ return typeof thing;
+};
+
+/**
+ * nou makes a null or undefined check the path of least resistance,
+ * encouraging developers to treat both as the same which encourages
+ * more predictable branching behavior.
+ * @param {*} v
+ * @returns {boolean}
+ */
+export const nou = v => v === null || v === undefined;
diff --git a/src/airouter.js/gemini/GeminiToolsAdapter.js b/src/airouter.js/gemini/GeminiToolsAdapter.js
new file mode 100644
index 0000000000..53f0728964
--- /dev/null
+++ b/src/airouter.js/gemini/GeminiToolsAdapter.js
@@ -0,0 +1,13 @@
+export class GeminiToolsAdapter {
+ static adapt_tools (tools) {
+ return [
+ {
+ function_declarations: tools.map(t => {
+ const tool = t.function;
+ delete tool.parameters.additionalProperties;
+ return tool;
+ })
+ }
+ ];
+ }
+}
diff --git a/src/airouter.js/openai/OpenAIToolsAdapter.js b/src/airouter.js/openai/OpenAIToolsAdapter.js
new file mode 100644
index 0000000000..b97787d1ec
--- /dev/null
+++ b/src/airouter.js/openai/OpenAIToolsAdapter.js
@@ -0,0 +1,5 @@
+export class OpenAIToolsAdapter {
+ static adapt_tools (tools) {
+ return tools;
+ }
+}
diff --git a/src/airouter.js/package.json b/src/airouter.js/package.json
new file mode 100644
index 0000000000..e10088c9fd
--- /dev/null
+++ b/src/airouter.js/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "@heyputer/airouter.js",
+ "version": "0.0.0",
+ "main": "airouter.js",
+ "type": "module",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "UNLICENSED",
+ "description": "",
+ "dependencies": {
+ "@anthropic-ai/sdk": "^0.56.0"
+ }
+}
diff --git a/src/backend/doc/api/spec.md b/src/backend/doc/api/spec.md
new file mode 100644
index 0000000000..96d79345bd
--- /dev/null
+++ b/src/backend/doc/api/spec.md
@@ -0,0 +1,35 @@
+# API Specification
+
+## AI Model Naming
+
+### Scope
+
+This rule applies to `model` field in AI-related API endpoints. Such as:
+
+- `puter-chat-completion/complete`
+
+### Request Format
+
+- interface (type: string, required) (`"puter-chat-completion"`)
+- method (type: string, required) (`"complete"`)
+- driver (type: string, optional) (`"ai-chat"` / `"openrouter"`)
+- args
+ - model (type: string, required) (e.g., `"gpt-4o"`, `"openai/gpt-4o"`, `"azure:openai/gpt-4o"`)
+ - messages (type: array, required)
+ - (e.g., `["Hello! How are you?"]`)
+ - (e.g., `[{ content: "Hello! How are you?" }]`)
+
+### Rule
+
+- 3 formats are allowed:
+ - `` (e.g., `gpt-4o`)
+ - `/` (e.g., `openai/gpt-4o`)
+ - `:/` (e.g., `azure:openai/gpt-4o`)
+-
+- All models have a vender name. But not all models have a supplier name.
+- When the supplier name is specified, the validation of the model name is done by the supplier instead of puter backend.
+- We should only initialize an AI service if the corresponding config is given.
+
+- Backend will pick a model when multiple candidates match the request. For instance, if a request specifies `gpt-4o`, the system will pick the most affordable model that matches `gpt-4o` from all available models.
+- Backend will return an error on invalid model names and invalid formats.
+- Available models are defined in this [list](https://puter.com/puterai/chat/models).
diff --git a/src/backend/doc/modules/filesystem/API_SPEC.md b/src/backend/doc/modules/filesystem/API_SPEC.md
new file mode 100644
index 0000000000..8ca72cead4
--- /dev/null
+++ b/src/backend/doc/modules/filesystem/API_SPEC.md
@@ -0,0 +1,69 @@
+# Filesystem API
+
+Filesystem endpoints allow operations on files and directories in the Puter filesystem.
+
+## POST `/mkdir` (auth required)
+
+### Description
+
+Creates a new directory in the filesystem. Currently support 2 formats:
+
+- Full path: `{"path": "/foo/bar", args ...}` — this API is used by apitest (`./tools/api-tester/apitest.js`) and aligns more closely with the POSIX spec (https://linux.die.net/man/3/mkdir)
+- Parent + path: `{"parent": "/foo", "path": "bar", args ...}` — this API is used by `puter-js` via `puter.fs.mkdir`
+
+A future work would be use a unified format for all filesystem operations.
+
+### Parameters
+
+- **path** _- required_
+ - **accepts:** `string`
+ - **description:** The path where the directory should be created
+ - **notes:** Cannot be empty, null, or undefined
+
+- **parent** _- optional_
+ - **accepts:** `string | UUID`
+ - **description:** The parent directory path or UUID
+ - **notes:** If not provided, path is treated as full path
+
+- **overwrite** _- optional_
+ - **accepts:** `boolean`
+ - **default:** `false`
+ - **description:** Whether to overwrite existing files/directories
+
+- **dedupe_name** _- optional_
+ - **accepts:** `boolean`
+ - **default:** `false`
+ - **description:** Whether to automatically rename if name exists
+
+- **create_missing_parents** _- optional_
+ - **accepts:** `boolean`
+ - **default:** `false`
+ - **description:** Whether to create parent directories if they don't exist
+ - **aliases:** `create_missing_ancestors`
+
+- **shortcut_to** _- optional_
+ - **accepts:** `string | UUID`
+ - **description:** Creates a shortcut/symlink to the specified target
+
+### Example
+
+```json
+{
+ "path": "/user/Desktop/new-directory"
+}
+```
+
+```json
+{
+ "parent": "/user",
+ "path": "Desktop/new-directory"
+}
+```
+
+### Response
+
+Returns the created directory's metadata including name, path, uid, and any parent directories created.
+
+## Other Filesystem Endpoints
+
+[Additional endpoints would be documented here...]
\ No newline at end of file
diff --git a/src/backend/doc/modules/puterai/api-specification.md b/src/backend/doc/modules/puterai/api-specification.md
new file mode 100644
index 0000000000..2fa7350c68
--- /dev/null
+++ b/src/backend/doc/modules/puterai/api-specification.md
@@ -0,0 +1,689 @@
+# Puter AI API Specification
+
+This document describes the API interfaces for Puter's AI services, including chat completions, image generation, and text-to-speech capabilities.
+
+## Table of Contents
+
+1. [Chat Completions](#chat-completions)
+2. [Image Generation](#image-generation)
+3. [Text-to-Speech](#text-to-speech)
+4. [Model Management](#model-management)
+5. [Common Patterns](#common-patterns)
+
+---
+
+## Chat Completions
+
+### Overview
+
+Chat completions allow you to send messages to AI language models and receive intelligent responses. This interface supports multiple providers including OpenAI, Claude, Gemini, and others.
+
+The `puter-chat-completion` interface provides a standardized way to interact with various Large Language Model (LLM) providers through Puter's driver system. This interface abstracts away provider-specific implementations and provides a consistent API for chat completions, model listing, and model information retrieval.
+
+### Interface: `puter-chat-completion`
+
+#### Method: `complete`
+
+**Summary**: Send a conversation to an AI model and receive a completion response.
+
+**Description**: Get completions for a chat log with support for multiple input formats including text prompts, conversation arrays, and vision capabilities.
+
+**Parameters**:
+- `messages` (type: `json`, required): Array of chat messages or content array for vision
+ - Format: Array of message objects with `role` and `content` properties
+ - For vision: Content array with text and image objects
+ - Example: `[{ role: "user", content: "Hello!" }, { role: "assistant", content: "Hi there!" }]`
+ - Vision Example: `[{ content: ["Describe this image", { image_url: { url: "data:image/..." } }] }]`
+- `model` (type: `string`, optional): Specific model to use for completion
+- `temperature` (type: `number`, optional): Controls randomness (0.0 to 2.0)
+- `max_tokens` (type: `number`, optional): Maximum number of tokens to generate
+- `stream` (type: `boolean`, optional): Whether to stream the response
+- `tools` (type: `json`, optional): Function calling tools to make available
+- `vision` (type: `boolean`, optional): Whether to enable vision capabilities (auto-detected)
+- `response` (type: `json`, optional): Response format specification
+- `driver` (type: `string`, optional): Override automatic driver selection
+
+**Result**: JSON response with completion data
+
+#### puter.js Usage
+
+The `puter.ai.chat()` method supports multiple function signatures for different use cases:
+
+**Basic Text Chat**:
+```javascript
+// Simple text prompt
+const response = await puter.ai.chat("Hello, how are you?");
+
+// With specific model and parameters
+const response = await puter.ai.chat("Explain quantum computing", {
+ model: "gpt-4o",
+ temperature: 0.8,
+ max_tokens: 1000
+});
+```
+
+**Conversation History**:
+```javascript
+// With conversation array
+const response = await puter.ai.chat([
+ { role: "user", content: "What is AI?" },
+ { role: "assistant", content: "AI stands for Artificial Intelligence..." },
+ { role: "user", content: "Can you give me examples?" }
+]);
+
+// Simple message array (auto-converted to proper format)
+const response = await puter.ai.chat(["hi"]);
+const response = await puter.ai.chat(["Hello", "How are you?"]);
+```
+
+**Vision Capabilities**:
+```javascript
+// Single image with prompt
+const response = await puter.ai.chat("Describe this image", imageFile);
+
+// Single image with prompt and test mode
+const response = await puter.ai.chat("Describe this image", imageFile, true);
+
+// Multiple images with prompt
+const response = await puter.ai.chat("Compare these images", [image1, image2, image3]);
+
+// Image URLs
+const response = await puter.ai.chat("Analyze this image", "https://example.com/image.jpg");
+```
+
+**Advanced Parameters**:
+```javascript
+// Function calling with tools
+const response = await puter.ai.chat("What's the weather like?", {
+ tools: [
+ {
+ type: "function",
+ function: {
+ name: "get_weather",
+ description: "Get current weather for a location",
+ parameters: {
+ type: "object",
+ properties: {
+ location: { type: "string" }
+ },
+ required: ["location"]
+ }
+ }
+ }
+ ]
+});
+
+// Streaming response
+const response = await puter.ai.chat("Write a story", {
+ stream: true
+});
+
+// Override driver selection
+const response = await puter.ai.chat("Hello", {
+ driver: "claude"
+});
+```
+
+**Test Mode**:
+```javascript
+// Enable test mode (bypasses actual API calls)
+const response = await puter.ai.chat("Hello", true);
+const response = await puter.ai.chat("Hello", imageFile, true);
+const response = await puter.ai.chat("Hello", { testMode: true });
+```
+
+#### Function Signature Overloads
+
+The `chat` method automatically detects the input format and processes accordingly:
+
+1. **`chat(prompt: string)`** → Basic text completion
+2. **`chat(prompt: string, testMode: boolean)`** → Text with test mode
+3. **`chat(prompt: string, image: File|string)`** → Vision with single image
+4. **`chat(prompt: string, image: File|string, testMode: boolean)`** → Vision with test mode
+5. **`chat(prompt: string, images: File[]|string[])`** → Vision with multiple images
+6. **`chat(messages: Message[])`** → Conversation array with proper format
+7. **`chat(messages: string[])`** → Simple string array (auto-converted to proper format)
+8. **`chat(params: object)`** → Full parameter object
+9. **`chat(prompt: string, params: object)`** → Text with parameters
+
+#### Vision Processing
+
+When images are provided, the method automatically:
+- Converts File objects to data URIs
+- Sets `vision: true` flag
+- Structures content as arrays with text and image objects
+- Supports both single and multiple images
+
+**Image Format**:
+```javascript
+{
+ vision: true,
+ messages: [{
+ content: [
+ "Describe this image",
+ {
+ image_url: {
+ url: "data:image/jpeg;base64,..."
+ }
+ }
+ ]
+ }]
+}
+```
+
+#### Model Mapping and Driver Selection
+
+The system automatically maps models to appropriate drivers:
+
+**OpenAI Models** (driver: `openai-completion`):
+- `gpt-*` models (gpt-4o, gpt-3.5-turbo, etc.)
+- `openai/*` prefix is automatically removed
+
+**Claude Models** (driver: `claude`):
+- `claude-*` models
+- `anthropic/*` prefix is automatically removed
+- Model aliases: `claude` → `claude-3-7-sonnet-latest`
+
+**Gemini Models** (driver: `gemini`):
+- `gemini-1.5-flash`, `gemini-2.0-flash`
+
+**Groq Models** (driver: `groq`):
+- `mistral` → `mistral-large-latest`
+- `groq` → `llama3-8b-8192`
+- Various Llama models
+
+**OpenRouter Models** (driver: `openrouter`):
+- `openrouter:*` prefix
+- Meta Llama, Google, DeepSeek, x-AI models automatically prefixed
+
+**Special Models**:
+- `o1-mini` → `openrouter:openai/o1-mini`
+- `deepseek` → `deepseek-chat`
+
+#### Backend HTTP API
+
+**Endpoint**: `POST /drivers/call`
+
+**Request**:
+```json
+{
+ "interface": "puter-chat-completion",
+ "driver": "openai-completion",
+ "method": "complete",
+ "args": {
+ "messages": [
+ {
+ "role": "user",
+ "content": "Hello, how are you?"
+ }
+ ],
+ "model": "gpt-4o",
+ "temperature": 0.7,
+ "max_tokens": 1000,
+ "stream": false,
+ "vision": false
+ }
+}
+```
+
+**Response**:
+
+*Success Response*:
+```json
+{
+ "success": true,
+ "result": {
+ "message": {
+ "role": "assistant",
+ "content": "Hello! I'm doing well, thank you for asking. How can I help you today?"
+ },
+ "usage": {
+ "prompt_tokens": 10,
+ "completion_tokens": 25,
+ "total_tokens": 35
+ }
+ }
+}
+```
+
+*Error Response*:
+```json
+{
+ "success": false,
+ "error": {
+ "code": "rate_limit_exceeded",
+ "message": "Rate limit exceeded. Please try again later."
+ }
+}
+```
+
+**HTTP Status**: All responses return HTTP 200, even for errors. Check the `success` field in the response body to determine if the operation succeeded.
+
+#### Response Transformation
+
+The `puter.ai.chat()` method automatically transforms responses to provide convenient access to content:
+
+```javascript
+const response = await puter.ai.chat("Hello");
+
+// Direct content access
+console.log(response.toString()); // Returns response.message.content
+console.log(response.valueOf()); // Returns response.message.content
+
+// Standard response structure
+console.log(response.message.content); // The actual response text
+console.log(response.message.role); // Usually "assistant"
+console.log(response.usage); // Token usage information
+```
+
+#### Test Mode
+
+Test mode allows you to bypass actual API calls for development and testing:
+
+**Enabling Test Mode**:
+```javascript
+// Method 1: Boolean parameter
+const response = await puter.ai.chat("Hello", true);
+
+// Method 2: Object parameter
+const response = await puter.ai.chat("Hello", { testMode: true });
+
+// Method 3: With vision
+const response = await puter.ai.chat("Describe this", imageFile, true);
+```
+
+**Test Mode Behavior**:
+- Bypasses actual API calls to external providers
+- Returns mock responses for development/testing
+- Does not count against usage limits
+- Useful for integration testing and development
+- Automatically sets `test_mode: true` in backend requests
+
+#### Function Signature Detection
+
+The `puter.ai.chat()` method automatically detects the input format and processes parameters accordingly:
+
+**Parameter Detection Logic**:
+1. **String + Boolean**: `chat(prompt, true)` → Text with test mode
+2. **String + File/String**: `chat(prompt, image)` → Vision with single image
+3. **String + File/String + Boolean**: `chat(prompt, image, true)` → Vision with test mode
+4. **String + Array**: `chat(prompt, [image1, image2])` → Vision with multiple images
+5. **Array**: `chat([messages])` → Conversation array or simple strings
+6. **Object**: `chat({ messages, model, ... })` → Full parameter object
+
+**Simple Message Conversion**:
+- `chat(["hi"])` → Automatically converted to `[{ content: "hi" }]`
+- `chat(["Hello", "How are you?"])` → Converted to `[{ content: "Hello" }, { content: "How are you?" }]`
+
+**Parameter Merging**:
+- User parameters in object form are merged with detected parameters
+- Vision detection automatically sets `vision: true` when images are present
+- Test mode can be specified in multiple ways and is automatically detected
+- Model mapping and driver selection happen automatically
+
+**Example of Complex Detection**:
+```javascript
+// This automatically detects:
+// - Vision mode (due to image parameter)
+// - Test mode (due to boolean parameter)
+// - Maps to appropriate driver based on model
+const response = await puter.ai.chat(
+ "Describe this image",
+ imageFile,
+ true,
+ { model: "claude-3-opus" }
+);
+```
+
+---
+
+## Image Generation
+
+### Overview
+
+Generate images from text descriptions using AI models like DALL-E. Supports various image qualities and aspect ratios.
+
+### Interface: `puter-image-generation`
+
+#### Method: `generate`
+
+**Summary**: Create an image from a text prompt using AI image generation models.
+
+**Description**: Generate an image from a prompt.
+
+**Parameters**:
+- `prompt` (type: `string`, required): Text description of the desired image
+- `quality` (type: `string`, optional): Image quality setting
+
+**Result**: URL of the generated image.
+
+**Result Choices**:
+- `image`: Stream with content_type: 'image'
+- `url`: String URL with content_type: 'image'
+
+#### puter.js Usage
+
+```javascript
+// Generate image from text
+const image = await puter.ai.txt2img("A beautiful sunset over mountains");
+
+// Access the image
+console.log(image.src); // Image URL
+```
+
+#### Backend HTTP API
+
+**Endpoint**: `POST /drivers/call`
+
+**Request**:
+```json
+{
+ "interface": "puter-image-generation",
+ "method": "generate",
+ "args": {
+ "prompt": "A beautiful sunset over mountains"
+ }
+}
+```
+
+**Response**: Image blob with `Content-Type: image/*`
+
+---
+
+## Text-to-Speech
+
+### Overview
+
+Convert text into natural-sounding speech using various TTS engines and voices.
+
+### Interface: `puter-tts`
+
+#### Method: `synthesize`
+
+**Summary**: Convert text to speech using specified voice and engine.
+
+**Description**: Synthesize speech from text.
+
+**Parameters**:
+- `text` (type: `string`, required): Text to convert to speech
+- `voice` (type: `string`, required): Voice identifier
+- `language` (type: `string`, optional): Language code
+- `ssml` (type: `flag`, optional): Enable SSML markup
+- `engine` (type: `string`, optional): TTS engine to use
+
+**Result Choices**:
+- `audio`: Stream with content_type: 'audio'
+
+#### puter.js Usage
+
+```javascript
+// Convert text to speech
+const audio = await puter.ai.txt2speech("Hello world", "en-US-Standard-A");
+
+// Play the audio
+audio.play();
+```
+
+#### Backend HTTP API
+
+**Endpoint**: `POST /drivers/call`
+
+**Request**:
+```json
+{
+ "interface": "puter-tts",
+ "method": "synthesize",
+ "args": {
+ "text": "Hello world",
+ "voice": "en-US-Standard-A"
+ }
+}
+```
+
+**Response**: Audio stream with `Content-Type: audio/*`
+
+---
+
+## Model Management
+
+### Overview
+
+Discover and manage available AI models across different providers and capabilities.
+
+### Interface: `puter-chat-completion`
+
+#### Method: `list`
+
+**Summary**: Get a list of available AI models.
+
+**Description**: List supported models.
+
+**Parameters**: None
+
+**Result**: Array of model identifiers (strings)
+
+#### puter.js Usage
+
+**Model Management**:
+```javascript
+// List available models
+const models = await puter.ai.listModels();
+
+// List models by provider
+const openaiModels = await puter.ai.listModels("openai");
+const claudeModels = await puter.ai.listModels("claude");
+
+// Get model providers
+const providers = await puter.ai.listModelProviders();
+```
+
+#### Backend HTTP API
+
+**Endpoint**: `POST /drivers/call`
+
+**Request**:
+```json
+{
+ "interface": "puter-chat-completion",
+ "method": "list"
+}
+```
+
+**Response**:
+```json
+{
+ "success": true,
+ "result": [
+ "gpt-4o",
+ "gpt-3.5-turbo",
+ "claude-3-opus",
+ "claude-3-sonnet"
+ ]
+}
+```
+
+#### Method: `models`
+
+**Summary**: Get detailed information about available models including pricing and capabilities.
+
+**Description**: List supported models and their details.
+
+**Parameters**: None
+
+**Result**: JSON array with detailed model information including:
+- `id`: Model identifier
+- `provider`: Service provider name
+- `aliases`: Alternative names for the model
+- `context_length`: Maximum context length
+- `capabilities`: Available features
+- `pricing`: Cost information
+
+#### puter.js Usage
+
+```javascript
+// Get detailed model information
+const modelDetails = await puter.ai.listModels();
+```
+
+#### Backend HTTP API
+
+**Endpoint**: `POST /drivers/call`
+
+**Request**:
+```json
+{
+ "interface": "puter-chat-completion",
+ "method": "models"
+}
+```
+
+**Response**:
+```json
+{
+ "success": true,
+ "result": [
+ {
+ "id": "gpt-4o",
+ "provider": "openai",
+ "aliases": ["gpt4o"],
+ "context_length": 128000,
+ "capabilities": ["chat", "vision"],
+ "pricing": {
+ "input": 0.000005,
+ "output": 0.000015
+ }
+ }
+ ]
+}
+```
+
+---
+
+## Common Patterns
+
+### AI Model Naming and Mapping
+
+**Scope**: This rule applies to `model` field in AI-related API endpoints. Such as:
+- `puter-chat-completion/complete`
+
+**Request Format**:
+- interface (type: string, required) (`"puter-chat-completion"`)
+- method (type: string, required) (`"complete"`)
+- driver (type: string, optional) (auto-detected based on model)
+- args
+ - model (type: string, required) (e.g., `"gpt-4o"`, `"claude-3-opus"`, `"gemini-1.5-flash"`)
+ - messages (type: array, required)
+ - (e.g., `["Hello! How are you?"]`)
+ - (e.g., `[{ content: "Hello! How are you?" }]`)
+
+**Model Format Support**: Multiple formats are supported:
+- `` (e.g., `gpt-4o`, `claude-3-opus`)
+- `/` (e.g., `openai/gpt-4o`, `anthropic/claude-3-opus`)
+- `:/` (e.g., `azure:openai/gpt-4o`)
+
+**Automatic Model Mapping**: The system automatically maps models to appropriate drivers:
+
+**OpenAI Models** → `openai-completion` driver:
+- `gpt-*` models (gpt-4o, gpt-3.5-turbo, etc.)
+- `openai/*` prefix is automatically removed
+- Examples: `gpt-4o`, `openai/gpt-4o` → both use OpenAI driver
+
+**Claude Models** → `claude` driver:
+- `claude-*` models
+- `anthropic/*` prefix is automatically removed
+- Model aliases: `claude` → `claude-3-7-sonnet-latest`
+
+**Gemini Models** → `gemini` driver:
+- `gemini-1.5-flash`, `gemini-2.0-flash`
+
+**Groq Models** → `groq` driver:
+- `mistral` → `mistral-large-latest`
+- `groq` → `llama3-8b-8192`
+- Various Llama models
+
+**OpenRouter Models** → `openrouter` driver:
+- `openrouter:*` prefix
+- Meta Llama, Google, DeepSeek, x-AI models automatically prefixed
+- Examples: `meta-llama/Llama-3.1-8B-Instruct-Turbo` → `openrouter:meta-llama/Llama-3.1-8B-Instruct-Turbo`
+
+**Special Model Mappings**:
+- `o1-mini` → `openrouter:openai/o1-mini`
+- `deepseek` → `deepseek-chat`
+- `mistral` → `mistral-large-latest`
+
+### Driver Implementations
+
+The following services implement the `puter-chat-completion` interface:
+
+- **`ai-chat`**: Main AI chat service with fallback and provider selection
+- **`openai-completion`**: OpenAI API integration (GPT models)
+- **`claude`**: Anthropic Claude API integration
+- **`gemini`**: Google Gemini API integration
+- **`groq`**: Groq API integration (Llama models)
+- **`deepseek`**: DeepSeek API integration
+- **`xai`**: xAI Grok integration
+- **`openrouter`**: OpenRouter API integration (multiple providers)
+
+### Error Handling
+
+All API responses use HTTP 200 status. Check the `success` field in the response body:
+
+**Error Response Format**:
+```json
+{
+ "success": false,
+ "error": {
+ "code": "error_code",
+ "message": "Human-readable description"
+ }
+}
+```
+
+**Common Error Codes**:
+- `permission_denied`: User lacks permission to use the driver
+- `rate_limit_exceeded`: API rate limit exceeded
+- `usage_limit_exceeded`: User usage limit exceeded
+- `invalid_model`: Specified model is not available
+- `invalid_parameters`: Request parameters are invalid
+- `provider_error`: Error from the underlying AI provider
+- `moderation_error`: Content flagged by moderation systems
+
+### Streaming Responses
+
+When `stream: true` is specified, the response is returned as a stream with the following format:
+
+**Response Headers**:
+- `Content-Type: application/ndjson`
+- `Transfer-Encoding: chunked` (if applicable)
+
+**Response Body**: Newline-delimited JSON stream
+```json
+{"role": "assistant", "content": "Hello"}
+{"role": "assistant", "content": "! How"}
+{"role": "assistant", "content": " can I help"}
+{"role": "assistant", "content": " you today?"}
+```
+
+### Usage Tracking
+
+All chat completions are tracked for:
+- Token usage (prompt, completion, total)
+- Cost calculation based on provider pricing
+- Rate limiting and quota management
+- User analytics and billing
+
+### Testing Mode
+
+Set `test_mode: true` in the request to enable testing mode, which:
+- Bypasses actual API calls to external providers
+- Returns mock responses for development/testing
+- Does not count against usage limits
+- Useful for integration testing and development
+
+### Security and Permissions
+
+- **Authentication**: All requests require valid authentication
+- **Permissions**: Users must have permission to use specific driver interfaces
+- **Moderation**: Content is automatically checked against moderation policies
+- **Rate Limiting**: Requests are rate-limited per user and per provider
+- **Usage Limits**: Users are subject to usage limits based on their plan
diff --git a/src/backend/exports.js b/src/backend/exports.js
index d4a836b0cd..ad28359147 100644
--- a/src/backend/exports.js
+++ b/src/backend/exports.js
@@ -20,6 +20,7 @@ const CoreModule = require("./src/CoreModule.js");
const { Kernel } = require("./src/Kernel.js");
const DatabaseModule = require("./src/DatabaseModule.js");
const LocalDiskStorageModule = require("./src/LocalDiskStorageModule.js");
+const MemoryStorageModule = require("./src/MemoryStorageModule.js");
const SelfHostedModule = require("./src/modules/selfhosted/SelfHostedModule.js");
const { testlaunch } = require("./src/index.js");
const BaseService = require("./src/services/BaseService.js");
@@ -39,6 +40,7 @@ const { InternetModule } = require("./src/modules/internet/InternetModule.js");
const { CaptchaModule } = require("./src/modules/captcha/CaptchaModule.js");
const { EntityStoreModule } = require("./src/modules/entitystore/EntityStoreModule.js");
const { KVStoreModule } = require("./src/modules/kvstore/KVStoreModule.js");
+const { AIRouterModule } = require("./src/modules/airouter/AIRouterModule.js");
module.exports = {
helloworld: () => {
@@ -71,8 +73,10 @@ module.exports = {
WebModule,
DatabaseModule,
LocalDiskStorageModule,
+ MemoryStorageModule,
SelfHostedModule,
TestDriversModule,
+ AIRouterModule,
PuterAIModule,
BroadcastModule,
InternetModule,
diff --git a/src/backend/package.json b/src/backend/package.json
index 07d44101a2..bc0bce52f2 100644
--- a/src/backend/package.json
+++ b/src/backend/package.json
@@ -15,6 +15,7 @@
"@heyputer/kv.js": "^0.1.9",
"@heyputer/multest": "^0.0.2",
"@heyputer/putility": "^1.0.0",
+ "@heyputer/airouter": "^0.0.0",
"@mistralai/mistralai": "^1.3.4",
"@opentelemetry/api": "^1.4.1",
"@opentelemetry/auto-instrumentations-node": "^0.43.0",
diff --git a/src/backend/src/ExtensionService.js b/src/backend/src/ExtensionService.js
index 2c9cf1f9df..23409888d6 100644
--- a/src/backend/src/ExtensionService.js
+++ b/src/backend/src/ExtensionService.js
@@ -63,6 +63,7 @@ class ExtensionServiceState extends AdvancedBase {
mw,
route: path,
handler: handler,
+ ...(options.subdomain ? { subdomain: options.subdomain } : {}),
});
this.endpoints_.push(endpoint);
diff --git a/src/backend/src/MemoryStorageModule.js b/src/backend/src/MemoryStorageModule.js
new file mode 100644
index 0000000000..a8985460c4
--- /dev/null
+++ b/src/backend/src/MemoryStorageModule.js
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+class MemoryStorageModule {
+ async install (context) {
+ const services = context.get('services');
+ const MemoryStorageService = require("./services/MemoryStorageService");
+ services.registerService('memory-storage', MemoryStorageService);
+ }
+}
+
+module.exports = MemoryStorageModule;
diff --git a/src/backend/src/api/APIError.js b/src/backend/src/api/APIError.js
index 721e3962dc..d89c232ce9 100644
--- a/src/backend/src/api/APIError.js
+++ b/src/backend/src/api/APIError.js
@@ -17,6 +17,7 @@
* along with this program. If not, see .
*/
const { URLSearchParams } = require("node:url");
+const config = require("../config");
const { quot } = require('@heyputer/putility').libs.string;
/**
@@ -300,7 +301,7 @@ module.exports = class APIError {
// Subdomains
'subdomain_limit_reached': {
status: 400,
- message: ({ limit }) => `You have exceeded the number of subdomains under your current plan (${limit}).`,
+ message: ({ limit, isWorker }) => isWorker ? `You have exceeded the maximum number of workers for your plan! (${limit})`:`You have exceeded the number of subdomains under your current plan (${limit}).`,
},
'subdomain_reserved': {
status: 400,
@@ -518,14 +519,19 @@ module.exports = class APIError {
* is set to null. The first argument is used as the status code.
*
* @static
- * @param {number} status
- * @param {string|Error} message_or_source one of the following:
+ * @param {number|string} status
+ * @param {object} source
+ * @param {string|Error|object} fields one of the following:
* - a string to use as the error message
* - an Error object to use as the source of the error
* - an object with a message property to use as the error message
* @returns
*/
static create (status, source, fields = {}) {
+ if ( config.env === 'dev' ) {
+ console.trace('APIError.create', status, source, fields);
+ }
+
// Just the error code
if ( typeof status === 'string' ) {
const code = this.codes[status];
diff --git a/src/backend/src/filesystem/FSNodeContext.js b/src/backend/src/filesystem/FSNodeContext.js
index c6b718cab5..cc062c94c1 100644
--- a/src/backend/src/filesystem/FSNodeContext.js
+++ b/src/backend/src/filesystem/FSNodeContext.js
@@ -25,7 +25,7 @@ const { NodeInternalIDSelector, NodeChildSelector, NodeUIDSelector, RootNodeSele
const { Context } = require("../util/context");
const { NodeRawEntrySelector } = require("./node/selectors");
const { DB_READ } = require("../services/database/consts");
-const { UserActorType } = require("../services/auth/Actor");
+const { UserActorType, AppUnderUserActorType, Actor } = require("../services/auth/Actor");
const { PermissionUtil } = require("../services/auth/PermissionService");
/**
@@ -288,7 +288,7 @@ module.exports = class FSNodeContext {
controls,
});
- if ( entry === null ) {
+ if ( ! entry ) {
this.found = false;
this.entry = false;
} else {
@@ -564,6 +564,16 @@ module.exports = class FSNodeContext {
await this.fetchEntry();
return this.mysql_id;
}
+
+ if ( key === 'owner' ) {
+ const user_id = await this.get('user_id');
+ const actor = new Actor({
+ type: new UserActorType({
+ user: await get_user({ id: user_id }),
+ }),
+ });
+ return actor;
+ }
const values_from_entry = ['immutable', 'user_id', 'name', 'size', 'parent_uid', 'metadata'];
for ( const k of values_from_entry ) {
@@ -746,6 +756,9 @@ module.exports = class FSNodeContext {
username: res.owner?.username,
};
}
+ if ( ! ( actor.type === AppUnderUserActorType ) ) {
+ if ( fsentry.owner ) delete fsentry.owner.email;
+ }
const info = this.services.get('information');
diff --git a/src/backend/src/filesystem/hl_operations/hl_copy.js b/src/backend/src/filesystem/hl_operations/hl_copy.js
index 46c71af03e..000ba5f3c3 100644
--- a/src/backend/src/filesystem/hl_operations/hl_copy.js
+++ b/src/backend/src/filesystem/hl_operations/hl_copy.js
@@ -159,8 +159,8 @@ class HLCopy extends HLFilesystemOperation {
throw APIError.create('source_and_dest_are_the_same');
}
- if ( await is_ancestor_of(source.mysql_id, parent.mysql_id) ) {
- throw APIError('cannot_copy_item_into_itself');
+ if ( await is_ancestor_of(source.uid, parent.uid) ) {
+ throw APIError.create('cannot_copy_item_into_itself');
}
let overwritten;
diff --git a/src/backend/src/filesystem/hl_operations/hl_mkdir.js b/src/backend/src/filesystem/hl_operations/hl_mkdir.js
index e1df130117..851ea3f779 100644
--- a/src/backend/src/filesystem/hl_operations/hl_mkdir.js
+++ b/src/backend/src/filesystem/hl_operations/hl_mkdir.js
@@ -271,15 +271,34 @@ class HLMkdir extends HLFilesystemOperation {
});
}
+ // Unify the following formats:
+ // - full path: {"path":"/foo/bar", args...}, used by apitest (./tools/api-tester/apitest.js)
+ // - parent + path: {"parent": "/foo", "path":"bar", args...}, used by puter-js (puter.fs.mkdir("/foo/bar"))
+ if ( !values.parent && values.path ) {
+ values.parent = await fs.node(new NodePathSelector(_path.dirname(values.path)));
+ values.path = _path.basename(values.path);
+ }
+
let parent_node = values.parent || await fs.node(new RootNodeSelector());
console.log('USING PARENT', parent_node.selector.describe());
+
let target_basename = _path.basename(values.path);
+ // "top_parent" is the immediate parent of the target directory
+ // (e.g: /home/foo/bar -> /home/foo)
const top_parent = values.create_missing_parents
- ? await this._create_top_parent({ top_parent: parent_node })
+ ? await this._create_dir(parent_node)
: await this._get_existing_top_parent({ top_parent: parent_node })
;
+ // TODO: this can be removed upon completion of: https://github.com/HeyPuter/puter/issues/1352
+ if ( top_parent.isRoot ) {
+ // root directory is read-only
+ throw APIError.create('forbidden', null, {
+ message: 'Cannot create directories in the root directory.'
+ });
+ }
+
// `parent_node` becomes the parent of the last directory name
// specified under `path`.
parent_node = await this._create_parents({
@@ -312,12 +331,14 @@ class HLMkdir extends HLFilesystemOperation {
});
}
else if ( dedupe_name ) {
- const fsEntryFetcher = context.get('services').get('fsEntryFetcher');
+ const fs = context.get('services').get('filesystem');
+ const parent_selector = parent_node.selector;
for ( let i=1 ;; i++ ) {
let try_new_name = `${target_basename} (${i})`;
- const exists = await fsEntryFetcher.nameExistsUnderParent(
- existing.entry.parent_uid, try_new_name
- );
+ const selector = new NodeChildSelector(parent_selector, try_new_name);
+ const exists = await parent_node.provider.quick_check({
+ selector,
+ });
if ( ! exists ) {
target_basename = try_new_name;
break;
@@ -449,22 +470,30 @@ class HLMkdir extends HLFilesystemOperation {
return node;
}
- async _create_top_parent ({ top_parent }) {
- if ( await top_parent.exists() ) {
- if ( ! top_parent.entry.is_dir ) {
+ /**
+ * Creates a directory and all its ancestors.
+ *
+ * @param {FSNodeContext} dir - The directory to create.
+ * @returns {Promise} The created directory.
+ */
+ async _create_dir (dir) {
+ console.log('CREATING DIR', dir.selector.describe());
+
+ if ( await dir.exists() ) {
+ if ( ! dir.entry.is_dir ) {
throw APIError.create('dest_is_not_a_directory');
}
- return top_parent;
+ return dir;
}
const maybe_path_selector =
- top_parent.get_selector_of_type(NodePathSelector);
+ dir.get_selector_of_type(NodePathSelector);
if ( ! maybe_path_selector ) {
throw APIError.create('dest_does_not_exist');
}
- const path = maybe_path_selector.value;
+ let path = maybe_path_selector.value;
const fs = this.context.get('services').get('filesystem');
diff --git a/src/backend/src/filesystem/hl_operations/hl_move.js b/src/backend/src/filesystem/hl_operations/hl_move.js
index 340fe089c4..ecbe659421 100644
--- a/src/backend/src/filesystem/hl_operations/hl_move.js
+++ b/src/backend/src/filesystem/hl_operations/hl_move.js
@@ -147,7 +147,7 @@ class HLMove extends HLFilesystemOperation {
if ( await dest.exists() ) {
if ( ! values.overwrite && ! values.dedupe_name ) {
throw APIError.create('item_with_same_name_exists', null, {
- entry_name: target_name,
+ entry_name: await dest.get('name'),
});
}
diff --git a/src/backend/src/filesystem/hl_operations/hl_stat.js b/src/backend/src/filesystem/hl_operations/hl_stat.js
index 809620c4b5..53419aea3b 100644
--- a/src/backend/src/filesystem/hl_operations/hl_stat.js
+++ b/src/backend/src/filesystem/hl_operations/hl_stat.js
@@ -29,7 +29,8 @@ class HLStat extends HLFilesystemOperation {
const {
subject, user,
return_subdomains,
- return_permissions,
+ return_permissions, // Deprecated: kept for backwards compatiable with `return_shares`
+ return_shares,
return_versions,
return_size,
} = this.values;
@@ -55,7 +56,9 @@ class HLStat extends HLFilesystemOperation {
if (return_size) await subject.fetchSize(user);
if (return_subdomains) await subject.fetchSubdomains(user)
- if (return_permissions) await subject.fetchShares();
+ if (return_shares || return_permissions) {
+ await subject.fetchShares();
+ }
if (return_versions) await subject.fetchVersions();
await subject.fetchIsEmpty();
diff --git a/src/backend/src/filesystem/ll_operations/ll_mkdir.js b/src/backend/src/filesystem/ll_operations/ll_mkdir.js
index a097b2109c..f92f5cba50 100644
--- a/src/backend/src/filesystem/ll_operations/ll_mkdir.js
+++ b/src/backend/src/filesystem/ll_operations/ll_mkdir.js
@@ -31,112 +31,13 @@ class LLMkdir extends LLFilesystemOperation {
}
async _run () {
- const { context } = this;
- const { parent, name } = this.values;
-
- this.checkpoint('lock requested');
- this.log.noticeme('GET FSLOCK');
- const svc_fslock = context.get('services').get('fslock');
- this.log.noticeme('REQUESTING LOCK', {
- parent: await parent.get('path'),
- name,
- });
- const lock_handle = await svc_fslock.lock_child(
- await parent.get('path'),
- name,
- MODE_WRITE,
- );
- this.log.noticeme('GOT LOCK', {
- parent: await parent.get('path'),
+ const { parent, name, immutable } = this.values;
+ return await parent.provider.mkdir({
+ context: this.context,
+ parent,
name,
+ immutable,
});
- this.checkpoint('lock acquired');
-
- try {
- return await this._locked_run();
- } finally {
- await lock_handle.unlock();
- }
- }
- async _locked_run () {
- const { _path, uuidv4 } = this.modules;
- const { context } = this;
- const { parent, name, immutable, actor } = this.values;
-
- const ts = Math.round(Date.now() / 1000);
- const uid = uuidv4();
- const resourceService = context.get('services').get('resourceService');
- const svc_fsEntry = context.get('services').get('fsEntryService');
- const svc_event = context.get('services').get('event');
- const fs = context.get('services').get('filesystem');
-
- this.field('fsentry-uid', uid);
-
- const existing = await fs.node(
- new NodeChildSelector(parent.selector, name)
- );
-
- if ( await existing.exists() ) {
- throw APIError.create('item_with_same_name_exists', null, {
- entry_name: name,
- });
- }
-
- this.checkpoint('before acl');
- const svc_acl = context.get('services').get('acl');
- if ( ! await parent.exists() ) {
- throw APIError.create('subject_does_not_exist');
- }
- if ( ! await svc_acl.check(actor, parent, 'write') ) {
- throw await svc_acl.get_safe_acl_error(actor, parent, 'write');
- }
-
- resourceService.register({
- uid,
- status: RESOURCE_STATUS_PENDING_CREATE,
- });
-
- const raw_fsentry = {
- is_dir: 1,
- uuid: uid,
- parent_uid: await parent.get('uid'),
- path: _path.join(await parent.get('path'), name),
- user_id: actor.type.user.id,
- name,
- created: ts,
- accessed: ts,
- modified: ts,
- immutable: immutable ?? false,
- ...(this.values.thumbnail ? {
- thumbnail: this.values.thumbnail,
- } : {}),
- };
-
- this.log.debug('creating fsentry', { fsentry: raw_fsentry })
-
- this.checkpoint('about to enqueue insert');
- const entryOp = await svc_fsEntry.insert(raw_fsentry);
-
- this.field('fsentry-created', false);
-
- this.checkpoint('enqueued insert');
- // Asynchronous behaviour temporarily disabled
- // (async () => {
- await entryOp.awaitDone();
- this.log.debug('finished creating fsentry', { uid })
- resourceService.free(uid);
- this.field('fsentry-created', true);
- // })();
-
- const node = await fs.node(new NodeUIDSelector(uid));
-
- svc_event.emit('fs.create.directory', {
- node,
- context: Context.get(),
- });
-
- this.checkpoint('returning node');
- return node
}
}
diff --git a/src/backend/src/filesystem/ll_operations/ll_read.js b/src/backend/src/filesystem/ll_operations/ll_read.js
index 91abf5acb3..1173cc44d6 100644
--- a/src/backend/src/filesystem/ll_operations/ll_read.js
+++ b/src/backend/src/filesystem/ll_operations/ll_read.js
@@ -18,6 +18,7 @@
*/
const APIError = require("../../api/APIError");
const { Sequence } = require("../../codex/Sequence");
+const { MemoryFSProvider } = require("../../modules/puterfs/customfs/MemoryFSProvider");
const { DB_WRITE } = require("../../services/database/consts");
const { buffer_to_stream } = require("../../util/streamutil");
@@ -115,10 +116,13 @@ class LLRead extends LLFilesystemOperation {
},
async function create_S3_read_stream (a) {
const context = a.iget('context');
- const storage = context.get('storage');
const { fsNode, version_id, offset, length, has_range } = a.values();
+ const svc_mountpoint = context.get('services').get('mountpoint');
+ const provider = await svc_mountpoint.get_provider(fsNode.selector);
+ const storage = svc_mountpoint.get_storage(provider.constructor);
+
// Empty object here is in the case of local fiesystem,
// where s3:location will return null.
// TODO: storage interface shouldn't have S3-specific properties.
@@ -133,6 +137,8 @@ class LLRead extends LLFilesystemOperation {
...(has_range ? {
range: `bytes=${offset}-${offset+length-1}`
} : {}),
+
+ memory_file: fsNode.entry,
}));
a.set('stream', stream);
@@ -144,8 +150,11 @@ class LLRead extends LLFilesystemOperation {
const { fsNode, stream, has_range } = a.values();
if ( ! has_range ) {
- const res = await svc_fileCache.maybe_store(fsNode, stream);
- if ( res.stream ) a.set('stream', res.stream);
+ // only cache for non-memoryfs providers
+ if ( ! (fsNode.provider instanceof MemoryFSProvider) ) {
+ const res = await svc_fileCache.maybe_store(fsNode, stream);
+ if ( res.stream ) a.set('stream', res.stream);
+ }
}
},
async function return_stream (a) {
diff --git a/src/backend/src/filesystem/ll_operations/ll_rmdir.js b/src/backend/src/filesystem/ll_operations/ll_rmdir.js
index 08add7c7d4..302070745a 100644
--- a/src/backend/src/filesystem/ll_operations/ll_rmdir.js
+++ b/src/backend/src/filesystem/ll_operations/ll_rmdir.js
@@ -17,6 +17,7 @@
* along with this program. If not, see .
*/
const APIError = require("../../api/APIError");
+const { MemoryFSProvider } = require("../../modules/puterfs/customfs/MemoryFSProvider");
const { ParallelTasks } = require("../../util/otelutil");
const FSNodeContext = require("../FSNodeContext");
const { NodeUIDSelector } = require("../node/selectors");
@@ -102,14 +103,27 @@ class LLRmDir extends LLFilesystemOperation {
}
await tasks.awaitAll();
- if ( ! descendants_only ) {
- await target.provider.rmdir({
+
+ // TODO (xiaochen): consolidate these two branches
+ if ( target.provider instanceof MemoryFSProvider ) {
+ await target.provider.rmdir( {
context,
node: target,
options: {
- ignore_not_empty: true,
+ recursive,
+ descendants_only,
},
- });
+ } );
+ } else {
+ if ( ! descendants_only ) {
+ await target.provider.rmdir( {
+ context,
+ node: target,
+ options: {
+ ignore_not_empty: true,
+ },
+ } );
+ }
}
}
}
diff --git a/src/backend/src/filesystem/ll_operations/ll_write.js b/src/backend/src/filesystem/ll_operations/ll_write.js
index bfeaf96fcc..a8c896df10 100644
--- a/src/backend/src/filesystem/ll_operations/ll_write.js
+++ b/src/backend/src/filesystem/ll_operations/ll_write.js
@@ -145,24 +145,24 @@ class LLWriteBase extends LLFilesystemOperation {
}
}
+/**
+ * The "overwrite" write operation.
+ *
+ * This operation is used to write a file to an existing path.
+ *
+ * @extends LLWriteBase
+ */
class LLOWrite extends LLWriteBase {
async _run () {
- const {
- node, actor, immutable,
- file, tmp, fsentry_tmp,
- message,
- } = this.values;
-
- const svc = Context.get('services');
- const sizeService = svc.get('sizeService');
- const resourceService = svc.get('resourceService');
- const svc_fsEntry = svc.get('fsEntryService');
- const svc_event = svc.get('event');
+ const node = this.values.node;
- // TODO: fs:decouple-versions
- // add version hook externally so LLCWrite doesn't
- // need direct database access
- const db = svc.get('database').get(DB_WRITE, 'filesystem');
+ // Embed fields into this.context
+ this.context.set('immutable', this.values.immutable);
+ this.context.set('tmp', this.values.tmp);
+ this.context.set('fsentry_tmp', this.values.fsentry_tmp);
+ this.context.set('message', this.values.message);
+ this.context.set('actor', this.values.actor);
+ this.context.set('app_id', this.values.app_id);
// TODO: Add symlink write
if ( ! await node.exists() ) {
@@ -170,71 +170,21 @@ class LLOWrite extends LLWriteBase {
throw APIError.create('subject_does_not_exist');
}
- const svc_acl = this.context.get('services').get('acl');
- if ( ! await svc_acl.check(actor, node, 'write') ) {
- throw await svc_acl.get_safe_acl_error(actor, node, 'write');
- }
-
- const uid = await node.get('uid');
-
- const bucket_region = node.entry.bucket_region;
- const bucket = node.entry.bucket;
-
- const state_upload = await this._storage_upload({
- uuid: node.entry.uuid,
- bucket, bucket_region, file,
- tmp: {
- ...tmp,
- path: await node.get('path'),
- }
- });
-
- if ( fsentry_tmp?.thumbnail_promise ) {
- fsentry_tmp.thumbnail = await fsentry_tmp.thumbnail_promise;
- delete fsentry_tmp.thumbnail_promise;
- }
-
- const ts = Math.round(Date.now() / 1000);
- const raw_fsentry_delta = {
- modified: ts,
- accessed: ts,
- size: file.size,
- ...fsentry_tmp,
- };
-
- resourceService.register({
- uid,
- status: RESOURCE_STATUS_PENDING_CREATE,
- });
-
- const filesize = file.size;
- sizeService.change_usage(actor.type.user.id, filesize);
-
- const entryOp = await svc_fsEntry.update(uid, raw_fsentry_delta);
-
- // depends on fsentry, does not depend on S3
- (async () => {
- await entryOp.awaitDone();
- this.log.debug('[owrite] finished creating fsentry', { uid })
- resourceService.free(uid);
- })();
-
- state_upload.post_insert({
- db, user: actor.type.user, node, uid, message, ts,
- });
-
- const svc_fileCache = this.context.get('services').get('file-cache');
- await svc_fileCache.invalidate(node);
-
- svc_event.emit('fs.write.file', {
- node,
+ return await node.provider.write_overwrite({
context: this.context,
+ node: node,
+ file: this.values.file,
});
-
- return node;
}
}
+/**
+ * The "non-overwrite" write operation.
+ *
+ * This operation is used to write a file to a non-existent path.
+ *
+ * @extends LLWriteBase
+ */
class LLCWrite extends LLWriteBase {
static MODULES = {
_path: require('path'),
@@ -243,144 +193,26 @@ class LLCWrite extends LLWriteBase {
}
async _run () {
- const { _path, uuidv4, config } = this.modules;
- const {
- parent, name, immutable,
- file, tmp, fsentry_tmp,
- message,
-
- actor: actor_let,
- app_id,
- } = this.values;
- let actor = actor_let;
-
- const svc = Context.get('services');
- const sizeService = svc.get('sizeService');
- const resourceService = svc.get('resourceService');
- const svc_fsEntry = svc.get('fsEntryService');
- const svc_event = svc.get('event');
- const fs = svc.get('filesystem');
-
- // TODO: fs:decouple-versions
- // add version hook externally so LLCWrite doesn't
- // need direct database access
- const db = svc.get('database').get(DB_WRITE, 'filesystem');
-
- const uid = uuidv4();
- this.field('fsentry-uid', uid);
+ const parent = this.values.parent;
- // determine bucket region
- let bucket_region = config.s3_region ?? config.region;
- let bucket = config.s3_bucket;
+ // Embed fields into this.context
+ this.context.set('immutable', this.values.immutable);
+ this.context.set('tmp', this.values.tmp);
+ this.context.set('fsentry_tmp', this.values.fsentry_tmp);
+ this.context.set('message', this.values.message);
+ this.context.set('actor', this.values.actor);
+ this.context.set('app_id', this.values.app_id);
- this.checkpoint('before acl');
if ( ! await parent.exists() ) {
throw APIError.create('subject_does_not_exist');
}
- const svc_acl = this.context.get('services').get('acl');
- actor = actor ?? Context.get('actor');
- if ( ! await svc_acl.check(actor, parent, 'write') ) {
- throw await svc_acl.get_safe_acl_error(actor, parent, 'write');
- }
-
- this.checkpoint('before storage upload');
-
- const storage_resp = await this._storage_upload({
- uuid: uid,
- bucket, bucket_region, file,
- tmp: {
- ...tmp,
- path: _path.join(await parent.get('path'), name),
- }
- });
-
- this.checkpoint('after storage upload');
-
- fsentry_tmp.thumbnail = await fsentry_tmp.thumbnail_promise;
- delete fsentry_tmp.thumbnail_promise;
-
- this.checkpoint('after thumbnail promise');
-
- const ts = Math.round(Date.now() / 1000);
- const raw_fsentry = {
- uuid: uid,
- is_dir: 0,
- user_id: actor.type.user.id,
- created: ts,
- accessed: ts,
- modified: ts,
- parent_uid: await parent.get('uid'),
- name,
- size: file.size,
- path: _path.join(await parent.get('path'), name),
- ...fsentry_tmp,
-
- bucket_region,
- bucket,
-
- associated_app_id: app_id ?? null,
- };
-
- svc_event.emit('fs.pending.file', {
- fsentry: FSNodeContext.sanitize_pending_entry_info(raw_fsentry),
- context: this.context,
- })
-
- this.checkpoint('after emit pending file');
-
- resourceService.register({
- uid,
- status: RESOURCE_STATUS_PENDING_CREATE,
- });
-
- const filesize = file.size;
- sizeService.change_usage(actor.type.user.id, filesize);
-
- this.checkpoint('after change_usage');
-
- const entryOp = await svc_fsEntry.insert(raw_fsentry);
-
- this.checkpoint('after fsentry insert enqueue');
-
- (async () => {
- await entryOp.awaitDone();
- this.log.debug('finished creating fsentry', { uid })
- resourceService.free(uid);
-
- const new_item_node = await fs.node(new NodeUIDSelector(uid));
- const new_item = await new_item_node.get('entry');
- const store_version_id = storage_resp.VersionId;
- if( store_version_id ){
- // insert version into db
- db.write(
- "INSERT INTO `fsentry_versions` (`user_id`, `fsentry_id`, `fsentry_uuid`, `version_id`, `message`, `ts_epoch`) VALUES (?, ?, ?, ?, ?, ?)",
- [
- actor.type.user.id,
- new_item.id,
- new_item.uuid,
- store_version_id,
- message ?? null,
- ts,
- ]
- );
- }
- })();
-
- this.checkpoint('after version IIAFE');
-
- const node = await fs.node(new NodeUIDSelector(uid));
-
- this.checkpoint('after create FSNodeContext');
-
- svc_event.emit('fs.create.file', {
- node,
+ return await parent.provider.write_new({
context: this.context,
+ parent,
+ name: this.values.name,
+ file: this.values.file,
});
-
- this.checkpoint('return result');
-
- return node;
}
}
diff --git a/src/backend/src/filesystem/node/selectors.js b/src/backend/src/filesystem/node/selectors.js
index 1501a81fa9..8aacabe708 100644
--- a/src/backend/src/filesystem/node/selectors.js
+++ b/src/backend/src/filesystem/node/selectors.js
@@ -19,8 +19,14 @@
const _path = require('path');
const { PuterPath } = require('../lib/PuterPath');
-class NodePathSelector {
+class NodeSelector {
+ path = null;
+ uid = null;
+}
+
+class NodePathSelector extends NodeSelector {
constructor (path) {
+ super();
this.value = path;
}
@@ -34,8 +40,9 @@ class NodePathSelector {
}
}
-class NodeUIDSelector {
+class NodeUIDSelector extends NodeSelector {
constructor (uid) {
+ super();
this.value = uid;
}
@@ -58,8 +65,9 @@ class NodeUIDSelector {
}
}
-class NodeInternalIDSelector {
+class NodeInternalIDSelector extends NodeSelector {
constructor (service, id, debugInfo) {
+ super();
this.service = service;
this.id = id;
this.debugInfo = debugInfo;
@@ -81,15 +89,20 @@ class NodeInternalIDSelector {
}
}
-class NodeChildSelector {
+class NodeChildSelector extends NodeSelector {
constructor (parent, name) {
+ super();
this.parent = parent;
this.name = name;
}
setPropertiesKnownBySelector (node) {
node.name = this.name;
- // no properties known
+
+ try_infer_attributes(this);
+ if ( this.path ) {
+ node.path = this.path;
+ }
}
describe () {
@@ -97,7 +110,7 @@ class NodeChildSelector {
}
}
-class RootNodeSelector {
+class RootNodeSelector extends NodeSelector {
static entry = {
is_dir: true,
is_root: true,
@@ -110,6 +123,7 @@ class RootNodeSelector {
node.uid = PuterPath.NULL_UUID;
}
constructor () {
+ super();
this.entry = this.constructor.entry;
}
@@ -118,8 +132,9 @@ class RootNodeSelector {
}
}
-class NodeRawEntrySelector {
+class NodeRawEntrySelector extends NodeSelector {
constructor (entry) {
+ super();
// Fix entries from get_descendants
if ( ! entry.uuid && entry.uid ) {
entry.uuid = entry.uid;
@@ -145,6 +160,30 @@ class NodeRawEntrySelector {
}
}
+/**
+ * Try to infer following attributes for a selector:
+ * - path
+ * - uid
+ *
+ * @param {NodeSelector} selector
+ */
+function try_infer_attributes (selector) {
+ if ( selector instanceof NodePathSelector ) {
+ selector.path = selector.value;
+ } else if ( selector instanceof NodeUIDSelector ) {
+ selector.uid = selector.value;
+ } else if ( selector instanceof NodeChildSelector ) {
+ try_infer_attributes(selector.parent);
+ if ( selector.parent.path ) {
+ selector.path = _path.join(selector.parent.path, selector.name);
+ }
+ } else if ( selector instanceof RootNodeSelector ) {
+ selector.path = '/';
+ } else {
+ // give up
+ }
+}
+
const relativeSelector = (parent, path) => {
if ( path === '.' ) return parent;
if ( path.startsWith('..') ) {
@@ -162,6 +201,7 @@ const relativeSelector = (parent, path) => {
}
module.exports = {
+ NodeSelector,
NodePathSelector,
NodeUIDSelector,
NodeInternalIDSelector,
@@ -169,4 +209,5 @@ module.exports = {
RootNodeSelector,
NodeRawEntrySelector,
relativeSelector,
+ try_infer_attributes,
};
diff --git a/src/backend/src/helpers.js b/src/backend/src/helpers.js
index 9abb815f08..19f6477fa7 100644
--- a/src/backend/src/helpers.js
+++ b/src/backend/src/helpers.js
@@ -966,7 +966,38 @@ const body_parser_error_handler = (err, req, res, next) => {
next();
}
+/**
+ * Given a uid, returns a file node.
+ *
+ * TODO (xiaochen): It only works for MemoryFSProvider currently.
+ *
+ * @param {string} uid - The uid of the file to get.
+ * @returns {Promise} The file node, or null if the file does not exist.
+ */
+async function get_entry(uid) {
+ const svc_mountpoint = Context.get('services').get('mountpoint');
+ const uid_selector = new NodeUIDSelector(uid);
+ const provider = await svc_mountpoint.get_provider(uid_selector);
+
+ // NB: We cannot import MemoryFSProvider here because it will cause a circular dependency.
+ if ( provider.constructor.name !== 'MemoryFSProvider' ) {
+ return null;
+ }
+
+ return provider.stat({
+ selector: uid_selector,
+ });
+}
+
async function is_ancestor_of(ancestor_uid, descendant_uid){
+ const ancestor = await get_entry(ancestor_uid);
+ const descendant = await get_entry(descendant_uid);
+
+ if ( ancestor && descendant ) {
+ return descendant.path.startsWith(ancestor.path);
+ }
+
+
/** @type BaseDatabaseAccessService */
const db = services.get('database').get(DB_READ, 'filesystem');
diff --git a/src/backend/src/modules/puterai/AIChatService.js b/src/backend/src/modules/airouter/AIChatService.js
similarity index 84%
rename from src/backend/src/modules/puterai/AIChatService.js
rename to src/backend/src/modules/airouter/AIChatService.js
index 6698caeea5..75e73f5f5a 100644
--- a/src/backend/src/modules/puterai/AIChatService.js
+++ b/src/backend/src/modules/airouter/AIChatService.js
@@ -28,13 +28,12 @@ const { TypeSpec } = require("../../services/drivers/meta/Construct");
const { TypedValue } = require("../../services/drivers/meta/Runtime");
const { Context } = require("../../util/context");
const { AsModeration } = require("./lib/AsModeration");
-const FunctionCalling = require("./lib/FunctionCalling");
-const Messages = require("./lib/Messages");
-const Streaming = require("./lib/Streaming");
// Maximum number of fallback attempts when a model fails, including the first attempt
const MAX_FALLBACKS = 3 + 1; // includes first attempt
+// Imported in _construct bleow.
+let CompletionWriter, UniversalPromptNormalizer, NormalizedPromptUtil, UniversalToolsNormalizer;
/**
* AIChatService class extends BaseService to provide AI chat completion functionality.
@@ -50,6 +49,41 @@ class AIChatService extends BaseService {
cuid2: require('@paralleldrive/cuid2').createId,
}
+ async ['__on_driver.register.interfaces'] () {
+ const svc_registry = this.services.get('registry');
+ const col_interfaces = svc_registry.get('interfaces');
+
+ col_interfaces.set('puter-chat-completion', {
+ description: 'Chatbot.',
+ methods: {
+ models: {
+ description: 'List supported models and their details.',
+ result: { type: 'json' },
+ parameters: {},
+ },
+ list: {
+ description: 'List supported models',
+ result: { type: 'json' },
+ parameters: {},
+ },
+ complete: {
+ description: 'Get completions for a chat log.',
+ parameters: {
+ messages: { type: 'json' },
+ tools: { type: 'json' },
+ vision: { type: 'flag' },
+ stream: { type: 'flag' },
+ response: { type: 'json' },
+ model: { type: 'string' },
+ temperature: { type: 'number' },
+ max_tokens: { type: 'number' },
+ },
+ result: { type: 'json' },
+ }
+ }
+ });
+ }
+
/**
* Initializes the service by setting up core properties.
@@ -58,12 +92,15 @@ class AIChatService extends BaseService {
* Called during service instantiation.
* @private
*/
- _construct () {
+ async _construct () {
this.providers = [];
this.simple_model_list = [];
this.detail_model_list = [];
this.detail_model_map = {};
+
+
+ ({ CompletionWriter, UniversalPromptNormalizer, NormalizedPromptUtil, UniversalToolsNormalizer } = await import("@heyputer/airouter.js"));
}
get_model_details (model_name, context) {
@@ -397,7 +434,7 @@ class AIChatService extends BaseService {
if ( parameters.messages ) {
parameters.messages =
- Messages.normalize_messages(parameters.messages);
+ UniversalPromptNormalizer.normalize_messages(parameters.messages);
}
if ( ! test_mode && ! await this.moderate(parameters) ) {
@@ -416,16 +453,30 @@ class AIChatService extends BaseService {
}
if ( parameters.tools ) {
- FunctionCalling.normalize_tools_object(parameters.tools);
+ UniversalToolsNormalizer.normalize_tools_object(parameters.tools);
}
- if ( intended_service === this.service_name ) {
- throw new Error('Calling ai-chat directly is not yet supported');
+ const { target_service, supplier, vendor, model } = this.disentangle_model(parameters.model, intended_service);
+ // Write "target_service" back to "intended_service".
+ //
+ // TODO (xiaochen): remove the redundant "target_service".
+ intended_service = target_service;
+
+ // Hardcode the services that accept / format. This will be
+ // removed in the future.
+ //
+ // TODO (xiaochen): pass the whole details (supplier, vendor, model) to the
+ // delegate service, and let it decide how to handle it.
+ if (intended_service === 'openrouter') {
+ parameters.model = vendor + '/' + model;
+ } else {
+ parameters.model = model;
}
const svc_driver = this.services.get('driver');
let ret, error;
let service_used = intended_service;
+
let model_used = this.get_model_from_request(parameters, {
intended_service
});
@@ -452,7 +503,7 @@ class AIChatService extends BaseService {
const model_input_cost = model_details.cost.input;
const model_output_cost = model_details.cost.output;
const model_max_tokens = model_details.max_tokens;
- const text = Messages.extract_text(parameters.messages);
+ const text = NormalizedPromptUtil.extract_text(parameters.messages);
const approximate_input_cost = text.length / 4 * model_input_cost;
const usageAllowed = await svc_cost.get_funding_allowed({
available,
@@ -668,7 +719,7 @@ class AIChatService extends BaseService {
chunked: true,
}, stream);
- const chatStream = new Streaming.AIChatStream({
+ const chatStream = new CompletionWriter({
stream,
});
@@ -719,7 +770,7 @@ class AIChatService extends BaseService {
if ( parameters.response?.normalize ) {
ret.result.message =
- Messages.normalize_single_message(ret.result.message);
+ UniversalPromptNormalizer.normalize_single_message(ret.result.message);
ret.result = {
message: ret.result.message,
via_ai_chat_service: true,
@@ -731,7 +782,6 @@ class AIChatService extends BaseService {
}
}
}
-
/**
* Checks if the user has permission to use AI services and verifies usage limits
@@ -973,6 +1023,118 @@ class AIChatService extends BaseService {
return model;
}
+
+ /**
+ * Determines the target service to use for AI operations based on intended service and model specification.
+ *
+ * The method follows this priority order:
+ * 1. If "intended_service" is not "ai-chat", use it as the target service.
+ * 2. If "supplier" is specified by model (e.g., "azure" in "azure:openai/gpt-4o"), use it as the target service.
+ * 3. If "vendor" is specified by model (e.g., "openai" in "openai/gpt-4o"), use it as the target service.
+ * 4. Try to infer the service from the model name itself. (e.g., "gpt-4o" -> "openai")
+ * 5. If all else fails, throw an error.
+ *
+ * Note that the "intended_service" corresponds to the "driver" field and "interface" field of the http request.
+ *
+ * This method also inherits the "fallback to the default model" feature from the old "get_model_from_request" api. This logic may be removed in the future.
+ *
+ * @param {string} qualified_model - The model string to parse
+ * @param {string} intended_service - The originally intended service name
+ * @returns {string} returns.target_service - The target service name to use for the AI operation
+ * @returns {string} returns.supplier - The supplier (e.g., "openrouter") or null if not specified
+ * @returns {string} returns.vendor - The vendor (e.g., "openai") or null if not specified
+ * @returns {string} returns.model - The model name (e.g., "gpt-4o")
+ */
+ disentangle_model(qualified_model, intended_service) {
+ if (!qualified_model) {
+ const service = this.services.get(intended_service);
+ if (!service.get_default_model) {
+ throw new Error('could not infer model from service');
+ }
+ qualified_model = service.get_default_model();
+ if (!qualified_model) {
+ throw new Error('could not infer model from service');
+ }
+ }
+
+ const { supplier, vendor, model } = parse_qualified_model(qualified_model);
+
+ let result = {
+ target_service: intended_service,
+ supplier,
+ vendor,
+ model,
+ };
+
+ if (intended_service !== 'ai-chat') {
+ // Already have a valid target service, no need to infer.
+ return result;
+ }
+
+ let service = null;
+ if (supplier) {
+ result.target_service = supplier;
+ } else if (vendor) {
+ result.target_service = vendor;
+ } else if (model) {
+ // TODO (xiaochen): infer the service from the model name
+ result.target_service = model;
+ }
+
+ const service_map = {
+ 'openai': 'openai-completion',
+ }
+ result.target_service = service_map[result.target_service] || result.target_service;
+
+ return result;
+ }
+}
+
+/**
+ * Parses a qualified model name string into its components.
+ *
+ * 3 formats are allowed:
+ * - `` (e.g., `gpt-4o`)
+ * - `/` (e.g., `openai/gpt-4o`)
+ * - `:/` (e.g., `azure:openai/gpt-4o`)
+ *
+ * @param {string} qualified_model - The model string to parse
+ * @returns {Object} Object containing parsed components
+ * @returns {string|null} returns.supplier - The supplier (e.g., "azure") or null if not specified
+ * @returns {string|null} returns.vendor - The vendor (e.g., "openai") or null if not specified
+ * @returns {string} returns.model - The model name (e.g., "gpt-4o")
+ * @throws {Error} If the input is not a string or has an invalid format
+ */
+function parse_qualified_model(qualified_model) {
+ if (typeof qualified_model !== 'string') {
+ throw new Error('Input must be a string');
+ }
+
+ let supplier = null;
+ let vendor = null;
+ let model_name = null;
+
+ // Match patterns
+ const fullPattern = /^([^:]+):([^/]+)\/(.+)$/; // supplier:vendor/model
+ const vendorPattern = /^([^/]+)\/(.+)$/; // vendor/model
+ const simplePattern = /^[^:/]+$/; // model
+
+ if (fullPattern.test(qualified_model)) {
+ const match = qualified_model.match(fullPattern);
+ supplier = match[1];
+ vendor = match[2];
+ model_name = match[3];
+ } else if (vendorPattern.test(qualified_model)) {
+ const match = qualified_model.match(vendorPattern);
+ vendor = match[1];
+ model_name = match[2];
+ } else if (simplePattern.test(qualified_model)) {
+ model_name = qualified_model;
+ } else {
+ throw new Error(`Invalid model format: "${qualified_model}"`);
+ }
+
+ return { supplier, vendor, model: model_name };
}
module.exports = { AIChatService };
diff --git a/src/backend/src/modules/airouter/AIRouterModule.js b/src/backend/src/modules/airouter/AIRouterModule.js
new file mode 100644
index 0000000000..13c7bd541f
--- /dev/null
+++ b/src/backend/src/modules/airouter/AIRouterModule.js
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+// METADATA // {"ai-commented":{"service":"claude"}}
+const { AdvancedBase } = require("@heyputer/putility");
+const config = require("../../config");
+
+
+/**
+* PuterAIModule class extends AdvancedBase to manage and register various AI services.
+* This module handles the initialization and registration of multiple AI-related services
+* including text processing, speech synthesis, chat completion, and image generation.
+* Services are conditionally registered based on configuration settings, allowing for
+* flexible deployment with different AI providers like AWS, OpenAI, Claude, Together AI,
+* Mistral, Groq, and XAI.
+* @extends AdvancedBase
+*/
+class AIRouterModule extends AdvancedBase {
+ /**
+ * Module for managing AI-related services in the Puter platform
+ * Extends AdvancedBase to provide core functionality
+ * Handles registration and configuration of various AI services like OpenAI, Claude, AWS services etc.
+ */
+ async install (context) {
+ const services = context.get('services');
+
+ if ( !! config?.openai ) {
+ const { OpenAICompletionService } = require('./OpenAICompletionService');
+ services.registerService('openai-completion', OpenAICompletionService);
+ }
+
+ if ( !! config?.services?.claude ) {
+ const { ClaudeService } = require('./ClaudeService');
+ services.registerService('claude', ClaudeService);
+ }
+
+ if ( !! config?.services?.['together-ai'] ) {
+ const { TogetherAIService } = require('./TogetherAIService');
+ services.registerService('together-ai', TogetherAIService);
+ }
+
+ if ( !! config?.services?.['mistral'] ) {
+ const { MistralAIService } = require('./MistralAIService');
+ services.registerService('mistral', MistralAIService);
+ }
+
+ if ( !! config?.services?.['groq'] ) {
+ const { GroqAIService } = require('./GroqAIService');
+ services.registerService('groq', GroqAIService);
+ }
+
+ if ( !! config?.services?.['xai'] ) {
+ const { XAIService } = require('./XAIService');
+ services.registerService('xai', XAIService);
+ }
+
+ if ( !! config?.services?.['deepseek'] ) {
+ const { DeepSeekService } = require('./DeepSeekService');
+ services.registerService('deepseek', DeepSeekService);
+ }
+ if ( !! config?.services?.['gemini'] ) {
+ const { GeminiService } = require('./GeminiService');
+ services.registerService('gemini', GeminiService);
+ }
+ if ( !! config?.services?.['openrouter'] ) {
+ const { OpenRouterService } = require('./OpenRouterService');
+ services.registerService('openrouter', OpenRouterService);
+ }
+
+ const { AIChatService } = require('./AIChatService');
+ services.registerService('ai-chat', AIChatService);
+
+ const { FakeChatService } = require('./FakeChatService');
+ services.registerService('fake-chat', FakeChatService);
+
+ const{ AITestModeService } = require('./AITestModeService');
+ services.registerService('ai-test-mode', AITestModeService);
+
+ const { UsageLimitedChatService } = require('./UsageLimitedChatService');
+ services.registerService('usage-limited-chat', UsageLimitedChatService);
+ }
+}
+
+module.exports = {
+ AIRouterModule,
+};
diff --git a/src/backend/src/modules/puterai/AITestModeService.js b/src/backend/src/modules/airouter/AITestModeService.js
similarity index 100%
rename from src/backend/src/modules/puterai/AITestModeService.js
rename to src/backend/src/modules/airouter/AITestModeService.js
diff --git a/src/backend/src/modules/puterai/ClaudeService.js b/src/backend/src/modules/airouter/ClaudeService.js
similarity index 81%
rename from src/backend/src/modules/puterai/ClaudeService.js
rename to src/backend/src/modules/airouter/ClaudeService.js
index 2e04764dad..1cd0e3c725 100644
--- a/src/backend/src/modules/puterai/ClaudeService.js
+++ b/src/backend/src/modules/airouter/ClaudeService.js
@@ -21,8 +21,6 @@
const { default: Anthropic, toFile } = require("@anthropic-ai/sdk");
const BaseService = require("../../services/BaseService");
const { TypedValue } = require("../../services/drivers/meta/Runtime");
-const FunctionCalling = require("./lib/FunctionCalling");
-const Messages = require("./lib/Messages");
const { NodePathSelector } = require("../../filesystem/node/selectors");
const FSNodeParam = require("../../api/filesystem/FSNodeParam");
const { LLRead } = require("../../filesystem/ll_operations/ll_read");
@@ -46,6 +44,15 @@ class ClaudeService extends BaseService {
*/
anthropic;
+ async _construct () {
+ const airouter = await import('@heyputer/airouter.js');
+ this.NormalizedPromptUtil = airouter.NormalizedPromptUtil;
+ this.AnthropicToolsAdapter = airouter.AnthropicToolsAdapter;
+ this.AnthropicStreamAdapter = airouter.AnthropicStreamAdapter;
+ this.anthropicApiType = new airouter.AnthropicAPIType();
+
+ }
+
/**
* Initializes the Claude service by creating an Anthropic client instance
@@ -114,10 +121,40 @@ class ClaudeService extends BaseService {
* @this {ClaudeService}
*/
async complete ({ messages, stream, model, tools, max_tokens, temperature}) {
- tools = FunctionCalling.make_claude_tools(tools);
+ if ( stream ) {
+ let usage_promise = new TeePromise();
+
+ let streamOperation;
+ const init_chat_stream = async ({ chatStream: completionWriter }) => {
+ streamOperation = await this.anthropicApiType.stream(this.anthropic, completionWriter, {
+ messages, model, tools, max_tokens, temperature,
+ })
+ await streamOperation.run();
+ // const input = await anthropic.messages.stream(sdk_params);
+ // await this.AnthropicStreamAdapter.write_to_stream(
+ // { input, completionWriter, usageWriter: usage_promise })
+ };
+
+ return new TypedValue({ $: 'ai-chat-intermediate' }, {
+ init_chat_stream,
+ stream: true,
+ usage_promise: usage_promise,
+ finally_fn: async () => {
+ await streamOperation.cleanup();
+ },
+ });
+ } else {
+ const syncOperation = await this.anthropicApiType.create(this.anthropic, {
+ messages, model, tools, max_tokens, temperature,
+ });
+ const retVal = await syncOperation.run();
+ await syncOperation.cleanup();
+ return retVal;
+ }
+ tools = this.AnthropicToolsAdapter.adapt_tools(tools);
let system_prompts;
- [system_prompts, messages] = Messages.extract_and_remove_system_messages(messages);
+ [system_prompts, messages] = this.NormalizedPromptUtil.extract_and_remove_system_messages(messages);
const sdk_params = {
model: model ?? this.get_default_model(),
@@ -242,64 +279,10 @@ class ClaudeService extends BaseService {
if ( stream ) {
let usage_promise = new TeePromise();
- const init_chat_stream = async ({ chatStream }) => {
- const completion = await anthropic.messages.stream(sdk_params);
- const counts = { input_tokens: 0, output_tokens: 0 };
-
- let message, contentBlock;
- for await ( const event of completion ) {
- const input_tokens =
- (event?.usage ?? event?.message?.usage)?.input_tokens;
- const output_tokens =
- (event?.usage ?? event?.message?.usage)?.output_tokens;
-
- if ( input_tokens ) counts.input_tokens += input_tokens;
- if ( output_tokens ) counts.output_tokens += output_tokens;
-
- if ( event.type === 'message_start' ) {
- message = chatStream.message();
- continue;
- }
- if ( event.type === 'message_stop' ) {
- message.end();
- message = null;
- continue;
- }
-
- if ( event.type === 'content_block_start' ) {
- if ( event.content_block.type === 'tool_use' ) {
- contentBlock = message.contentBlock({
- type: event.content_block.type,
- id: event.content_block.id,
- name: event.content_block.name,
- });
- continue;
- }
- contentBlock = message.contentBlock({
- type: event.content_block.type,
- });
- continue;
- }
-
- if ( event.type === 'content_block_stop' ) {
- contentBlock.end();
- contentBlock = null;
- continue;
- }
-
- if ( event.type === 'content_block_delta' ) {
- if ( event.delta.type === 'input_json_delta' ) {
- contentBlock.addPartialJSON(event.delta.partial_json);
- continue;
- }
- if ( event.delta.type === 'text_delta' ) {
- contentBlock.addText(event.delta.text);
- continue;
- }
- }
- }
- chatStream.end();
- usage_promise.resolve(counts);
+ const init_chat_stream = async ({ chatStream: completionWriter }) => {
+ const input = await anthropic.messages.stream(sdk_params);
+ await this.AnthropicStreamAdapter.write_to_stream(
+ { input, completionWriter, usageWriter: usage_promise })
};
return new TypedValue({ $: 'ai-chat-intermediate' }, {
@@ -337,6 +320,19 @@ class ClaudeService extends BaseService {
*/
async models_ () {
return [
+ {
+ id: 'claude-opus-4-1-20250805',
+ aliases: ['claude-opus-4-1'],
+ name: 'Claude Opus 4.1',
+ context: 200000,
+ cost: {
+ currency: 'usd-cents',
+ tokens: 1_000_000,
+ input: 1500,
+ output: 7500,
+ },
+ max_tokens: 32000,
+ },
{
id: 'claude-opus-4-20250514',
aliases: ['claude-opus-4', 'claude-opus-4-latest'],
diff --git a/src/backend/src/modules/puterai/DeepSeekService.js b/src/backend/src/modules/airouter/DeepSeekService.js
similarity index 100%
rename from src/backend/src/modules/puterai/DeepSeekService.js
rename to src/backend/src/modules/airouter/DeepSeekService.js
diff --git a/src/backend/src/modules/puterai/FakeChatService.js b/src/backend/src/modules/airouter/FakeChatService.js
similarity index 100%
rename from src/backend/src/modules/puterai/FakeChatService.js
rename to src/backend/src/modules/airouter/FakeChatService.js
diff --git a/src/backend/src/modules/puterai/GeminiService.js b/src/backend/src/modules/airouter/GeminiService.js
similarity index 95%
rename from src/backend/src/modules/puterai/GeminiService.js
rename to src/backend/src/modules/airouter/GeminiService.js
index b9f065e463..6c6a23ea41 100644
--- a/src/backend/src/modules/puterai/GeminiService.js
+++ b/src/backend/src/modules/airouter/GeminiService.js
@@ -3,9 +3,13 @@ const { GoogleGenerativeAI } = require('@google/generative-ai');
const GeminiSquareHole = require("./lib/GeminiSquareHole");
const { TypedValue } = require("../../services/drivers/meta/Runtime");
const putility = require("@heyputer/putility");
-const FunctionCalling = require("./lib/FunctionCalling");
+
+let OpenAIToolsAdapter;
class GeminiService extends BaseService {
+ async _construct () {
+ ({ OpenAIToolsAdapter } = await import("@heyputer/airouter.js"));
+ }
async _init () {
const svc_aiChat = this.services.get('ai-chat');
svc_aiChat.register_provider({
@@ -32,7 +36,7 @@ class GeminiService extends BaseService {
},
async complete ({ messages, stream, model, tools, max_tokens, temperature }) {
- tools = FunctionCalling.make_gemini_tools(tools);
+ tools = OpenAIToolsAdapter.adapt_tools(tools);
const genAI = new GoogleGenerativeAI(this.config.apiKey);
const genModel = genAI.getGenerativeModel({
diff --git a/src/backend/src/modules/puterai/GroqAIService.js b/src/backend/src/modules/airouter/GroqAIService.js
similarity index 100%
rename from src/backend/src/modules/puterai/GroqAIService.js
rename to src/backend/src/modules/airouter/GroqAIService.js
diff --git a/src/backend/src/modules/puterai/MistralAIService.js b/src/backend/src/modules/airouter/MistralAIService.js
similarity index 100%
rename from src/backend/src/modules/puterai/MistralAIService.js
rename to src/backend/src/modules/airouter/MistralAIService.js
diff --git a/src/backend/src/modules/puterai/OpenAICompletionService.js b/src/backend/src/modules/airouter/OpenAICompletionService.js
similarity index 91%
rename from src/backend/src/modules/puterai/OpenAICompletionService.js
rename to src/backend/src/modules/airouter/OpenAICompletionService.js
index a35a93e594..13121ff482 100644
--- a/src/backend/src/modules/puterai/OpenAICompletionService.js
+++ b/src/backend/src/modules/airouter/OpenAICompletionService.js
@@ -105,13 +105,56 @@ class OpenAICompletionService extends BaseService {
*/
async models_ () {
return [
+ {
+ id: 'gpt-5-2025-08-07',
+ aliases: ['gpt-5'],
+ cost: {
+ currency: 'usd-cents',
+ tokens: 1_000_000,
+ input: 125,
+ output: 1000,
+ },
+ max_tokens: 128000,
+ },
+ {
+ id: 'gpt-5-mini-2025-08-07',
+ aliases: ['gpt-5-mini'],
+ cost: {
+ currency: 'usd-cents',
+ tokens: 1_000_000,
+ input: 25,
+ output: 200,
+ },
+ max_tokens: 128000,
+ },
+ {
+ id: 'gpt-5-nano-2025-08-07',
+ aliases: ['gpt-5-nano'],
+ cost: {
+ currency: 'usd-cents',
+ tokens: 1_000_000,
+ input: 5,
+ output: 40,
+ },
+ max_tokens: 128000,
+ },
+ {
+ id: 'gpt-5-chat-latest',
+ cost: {
+ currency: 'usd-cents',
+ tokens: 1_000_000,
+ input: 125,
+ output: 1000,
+ },
+ max_tokens: 128000,
+ },
{
id: 'gpt-4o',
cost: {
currency: 'usd-cents',
tokens: 1_000_000,
input: 250,
- output: 1000, // https://platform.openai.com/docs/pricing
+ output: 1000,
},
max_tokens: 16384,
},
diff --git a/src/backend/src/modules/puterai/OpenRouterService.js b/src/backend/src/modules/airouter/OpenRouterService.js
similarity index 100%
rename from src/backend/src/modules/puterai/OpenRouterService.js
rename to src/backend/src/modules/airouter/OpenRouterService.js
diff --git a/src/backend/src/modules/puterai/TogetherAIService.js b/src/backend/src/modules/airouter/TogetherAIService.js
similarity index 100%
rename from src/backend/src/modules/puterai/TogetherAIService.js
rename to src/backend/src/modules/airouter/TogetherAIService.js
diff --git a/src/backend/src/modules/puterai/UsageLimitedChatService.js b/src/backend/src/modules/airouter/UsageLimitedChatService.js
similarity index 96%
rename from src/backend/src/modules/puterai/UsageLimitedChatService.js
rename to src/backend/src/modules/airouter/UsageLimitedChatService.js
index 7774709413..68867a0f12 100644
--- a/src/backend/src/modules/puterai/UsageLimitedChatService.js
+++ b/src/backend/src/modules/airouter/UsageLimitedChatService.js
@@ -22,7 +22,9 @@ const { default: dedent } = require("dedent");
const BaseService = require("../../services/BaseService");
const { PassThrough } = require("stream");
const { TypedValue } = require("../../services/drivers/meta/Runtime");
-const Streaming = require("./lib/Streaming");
+
+// Imported in _construct below
+let CompletionWriter;
/**
* UsageLimitedChatService - A specialized chat service that returns resource exhaustion messages.
@@ -31,6 +33,9 @@ const Streaming = require("./lib/Streaming");
* Can handle both streaming and non-streaming requests consistently.
*/
class UsageLimitedChatService extends BaseService {
+ async _construct () {
+ ({ CompletionWriter } = await import('@heyputer/airouter'));
+ }
get_default_model () {
return 'usage-limited';
}
@@ -85,7 +90,7 @@ class UsageLimitedChatService extends BaseService {
chunked: true,
}, streamObj);
- const chatStream = new Streaming.AIChatStream({
+ const chatStream = new CompletionWriter({
stream: streamObj,
});
diff --git a/src/backend/src/modules/puterai/XAIService.js b/src/backend/src/modules/airouter/XAIService.js
similarity index 100%
rename from src/backend/src/modules/puterai/XAIService.js
rename to src/backend/src/modules/airouter/XAIService.js
diff --git a/src/backend/src/modules/puterai/experiment/stream_claude.js b/src/backend/src/modules/airouter/experiment/stream_claude.js
similarity index 100%
rename from src/backend/src/modules/puterai/experiment/stream_claude.js
rename to src/backend/src/modules/airouter/experiment/stream_claude.js
diff --git a/src/backend/src/modules/puterai/experiment/stream_openai.js b/src/backend/src/modules/airouter/experiment/stream_openai.js
similarity index 100%
rename from src/backend/src/modules/puterai/experiment/stream_openai.js
rename to src/backend/src/modules/airouter/experiment/stream_openai.js
diff --git a/src/backend/src/modules/puterai/lib/AsModeration.js b/src/backend/src/modules/airouter/lib/AsModeration.js
similarity index 100%
rename from src/backend/src/modules/puterai/lib/AsModeration.js
rename to src/backend/src/modules/airouter/lib/AsModeration.js
diff --git a/src/backend/src/modules/puterai/lib/GeminiSquareHole.js b/src/backend/src/modules/airouter/lib/GeminiSquareHole.js
similarity index 100%
rename from src/backend/src/modules/puterai/lib/GeminiSquareHole.js
rename to src/backend/src/modules/airouter/lib/GeminiSquareHole.js
diff --git a/src/backend/src/modules/puterai/lib/OpenAIUtil.js b/src/backend/src/modules/airouter/lib/OpenAIUtil.js
similarity index 100%
rename from src/backend/src/modules/puterai/lib/OpenAIUtil.js
rename to src/backend/src/modules/airouter/lib/OpenAIUtil.js
diff --git a/src/backend/src/modules/puterai/samples/claude-1.js b/src/backend/src/modules/airouter/samples/claude-1.js
similarity index 100%
rename from src/backend/src/modules/puterai/samples/claude-1.js
rename to src/backend/src/modules/airouter/samples/claude-1.js
diff --git a/src/backend/src/modules/puterai/samples/claude-tools-1.js b/src/backend/src/modules/airouter/samples/claude-tools-1.js
similarity index 100%
rename from src/backend/src/modules/puterai/samples/claude-tools-1.js
rename to src/backend/src/modules/airouter/samples/claude-tools-1.js
diff --git a/src/backend/src/modules/puterai/samples/openai-1.js b/src/backend/src/modules/airouter/samples/openai-1.js
similarity index 100%
rename from src/backend/src/modules/puterai/samples/openai-1.js
rename to src/backend/src/modules/airouter/samples/openai-1.js
diff --git a/src/backend/src/modules/puterai/samples/openai-tools-1.js b/src/backend/src/modules/airouter/samples/openai-tools-1.js
similarity index 100%
rename from src/backend/src/modules/puterai/samples/openai-tools-1.js
rename to src/backend/src/modules/airouter/samples/openai-tools-1.js
diff --git a/src/backend/src/modules/puterai/AIInterfaceService.js b/src/backend/src/modules/puterai/AIInterfaceService.js
index 6f97e592a1..ebe77543c4 100644
--- a/src/backend/src/modules/puterai/AIInterfaceService.js
+++ b/src/backend/src/modules/puterai/AIInterfaceService.js
@@ -58,36 +58,6 @@ class AIInterfaceService extends BaseService {
}
});
- col_interfaces.set('puter-chat-completion', {
- description: 'Chatbot.',
- methods: {
- models: {
- description: 'List supported models and their details.',
- result: { type: 'json' },
- parameters: {},
- },
- list: {
- description: 'List supported models',
- result: { type: 'json' },
- parameters: {},
- },
- complete: {
- description: 'Get completions for a chat log.',
- parameters: {
- messages: { type: 'json' },
- tools: { type: 'json' },
- vision: { type: 'flag' },
- stream: { type: 'flag' },
- response: { type: 'json' },
- model: { type: 'string' },
- temperature: { type: 'number' },
- max_tokens: { type: 'number' },
- },
- result: { type: 'json' },
- }
- }
- });
-
col_interfaces.set('puter-image-generation', {
description: 'AI Image Generation.',
methods: {
diff --git a/src/backend/src/modules/puterai/PuterAIModule.js b/src/backend/src/modules/puterai/PuterAIModule.js
index 67e3d79a83..9629b9eb60 100644
--- a/src/backend/src/modules/puterai/PuterAIModule.js
+++ b/src/backend/src/modules/puterai/PuterAIModule.js
@@ -57,62 +57,9 @@ class PuterAIModule extends AdvancedBase {
}
if ( !! config?.openai ) {
- const { OpenAICompletionService } = require('./OpenAICompletionService');
- services.registerService('openai-completion', OpenAICompletionService);
-
const { OpenAIImageGenerationService } = require('./OpenAIImageGenerationService');
services.registerService('openai-image-generation', OpenAIImageGenerationService);
}
-
- if ( !! config?.services?.claude ) {
- const { ClaudeService } = require('./ClaudeService');
- services.registerService('claude', ClaudeService);
- }
-
- if ( !! config?.services?.['together-ai'] ) {
- const { TogetherAIService } = require('./TogetherAIService');
- services.registerService('together-ai', TogetherAIService);
- }
-
- if ( !! config?.services?.['mistral'] ) {
- const { MistralAIService } = require('./MistralAIService');
- services.registerService('mistral', MistralAIService);
- }
-
- if ( !! config?.services?.['groq'] ) {
- const { GroqAIService } = require('./GroqAIService');
- services.registerService('groq', GroqAIService);
- }
-
- if ( !! config?.services?.['xai'] ) {
- const { XAIService } = require('./XAIService');
- services.registerService('xai', XAIService);
- }
-
- if ( !! config?.services?.['deepseek'] ) {
- const { DeepSeekService } = require('./DeepSeekService');
- services.registerService('deepseek', DeepSeekService);
- }
- if ( !! config?.services?.['gemini'] ) {
- const { GeminiService } = require('./GeminiService');
- services.registerService('gemini', GeminiService);
- }
- if ( !! config?.services?.['openrouter'] ) {
- const { OpenRouterService } = require('./OpenRouterService');
- services.registerService('openrouter', OpenRouterService);
- }
-
- const { AIChatService } = require('./AIChatService');
- services.registerService('ai-chat', AIChatService);
-
- const { FakeChatService } = require('./FakeChatService');
- services.registerService('fake-chat', FakeChatService);
-
- const{ AITestModeService } = require('./AITestModeService');
- services.registerService('ai-test-mode', AITestModeService);
-
- const { UsageLimitedChatService } = require('./UsageLimitedChatService');
- services.registerService('usage-limited-chat', UsageLimitedChatService);
}
}
diff --git a/src/backend/src/modules/puterai/lib/Streaming.js b/src/backend/src/modules/puterai/lib/Streaming.js
deleted file mode 100644
index 5bbfb8bff5..0000000000
--- a/src/backend/src/modules/puterai/lib/Streaming.js
+++ /dev/null
@@ -1,92 +0,0 @@
-/**
- * Assign the properties of the override object to the original object,
- * like Object.assign, except properties are ordered so override properties
- * are enumerated first.
- *
- * @param {*} original
- * @param {*} override
- */
-const objectAssignTop = (original, override) => {
- let o = {
- ...original,
- ...override,
- };
- o = {
- ...override,
- ...original,
- };
- return o;
-}
-
-class AIChatConstructStream {
- constructor (chatStream, params) {
- this.chatStream = chatStream;
- if ( this._start ) this._start(params);
- }
- end () {
- if ( this._end ) this._end();
- }
-}
-
-class AIChatTextStream extends AIChatConstructStream {
- addText (text) {
- const json = JSON.stringify({
- type: 'text', text,
- });
- this.chatStream.stream.write(json + '\n');
- }
-}
-
-class AIChatToolUseStream extends AIChatConstructStream {
- _start (params) {
- this.contentBlock = params;
- this.buffer = '';
- }
- addPartialJSON (partial_json) {
- this.buffer += partial_json;
- }
- _end () {
- if ( this.buffer.trim() === '' ) {
- this.buffer = '{}';
- }
- if ( process.env.DEBUG ) console.log('BUFFER BEING PARSED', this.buffer);
- const str = JSON.stringify(objectAssignTop({
- ...this.contentBlock,
- input: JSON.parse(this.buffer),
- ...( ! this.contentBlock.text ? { text: "" } : {}),
- }, {
- type: 'tool_use',
- }));
- this.chatStream.stream.write(str + '\n');
- }
-}
-
-class AIChatMessageStream extends AIChatConstructStream {
- contentBlock ({ type, ...params }) {
- if ( type === 'tool_use' ) {
- return new AIChatToolUseStream(this.chatStream, params);
- }
- if ( type === 'text' ) {
- return new AIChatTextStream(this.chatStream, params);
- }
- throw new Error(`Unknown content block type: ${type}`);
- }
-}
-
-class AIChatStream {
- constructor ({ stream }) {
- this.stream = stream;
- }
-
- end () {
- this.stream.end();
- }
-
- message () {
- return new AIChatMessageStream(this);
- }
-}
-
-module.exports = class Streaming {
- static AIChatStream = AIChatStream;
-}
diff --git a/src/backend/src/modules/puterai/lib/messages.test.js b/src/backend/src/modules/puterai/lib/messages.test.js
deleted file mode 100644
index 3a4c7b4e3e..0000000000
--- a/src/backend/src/modules/puterai/lib/messages.test.js
+++ /dev/null
@@ -1,184 +0,0 @@
-import { describe, it, expect } from 'vitest';
-const Messages = require('./Messages.js');
-const OpenAIUtil = require('./OpenAIUtil.js');
-
-describe('Messages', () => {
- describe('normalize_single_message', () => {
- const cases = [
- {
- name: 'string message',
- input: 'Hello, world!',
- output: {
- role: 'user',
- content: [
- {
- type: 'text',
- text: 'Hello, world!',
- }
- ]
- }
- }
- ];
- for ( const tc of cases ) {
- it(`should normalize ${tc.name}`, () => {
- const output = Messages.normalize_single_message(tc.input);
- expect(output).toEqual(tc.output);
- });
- }
- });
- describe('extract_text', () => {
- const cases = [
- {
- name: 'string message',
- input: ['Hello, world!'],
- output: 'Hello, world!',
- },
- {
- name: 'object message',
- input: [{
- content: [
- {
- type: 'text',
- text: 'Hello, world!',
- }
- ]
- }],
- output: 'Hello, world!',
- },
- {
- name: 'irregular messages',
- input: [
- 'First Part',
- {
- content: [
- {
- type: 'text',
- text: 'Second Part',
- }
- ]
- },
- {
- content: 'Third Part',
- }
- ],
- output: 'First Part Second Part Third Part',
- }
- ];
- for ( const tc of cases ) {
- it(`should extract text from ${tc.name}`, () => {
- const output = Messages.extract_text(tc.input);
- expect(output).toBe(tc.output);
- });
- }
- });
- describe('normalize OpenAI tool calls', () => {
- const cases = [
- {
- name: 'string message',
- input: {
- role: 'assistant',
- tool_calls: [
- {
- id: 'tool-1',
- type: 'function',
- function: {
- name: 'tool-1-function',
- arguments: {},
- }
- }
- ]
- },
- output: {
- role: 'assistant',
- content: [
- {
- type: 'tool_use',
- id: 'tool-1',
- name: 'tool-1-function',
- input: {},
- }
- ]
- }
- }
- ];
- for ( const tc of cases ) {
- it(`should normalize ${tc.name}`, () => {
- const output = Messages.normalize_single_message(tc.input);
- expect(output).toEqual(tc.output);
- });
- }
- });
- describe('normalize Claude tool calls', () => {
- const cases = [
- {
- name: 'string message',
- input: {
- role: 'assistant',
- content: [
- {
- type: 'tool_use',
- id: 'tool-1',
- name: 'tool-1-function',
- input: "{}",
- }
- ]
- },
- output: {
- role: 'assistant',
- content: [
- {
- type: 'tool_use',
- id: 'tool-1',
- name: 'tool-1-function',
- input: "{}",
- }
- ]
- }
- }
- ];
- for ( const tc of cases ) {
- it(`should normalize ${tc.name}`, () => {
- const output = Messages.normalize_single_message(tc.input);
- expect(output).toEqual(tc.output);
- });
- }
- });
- describe('OpenAI-ify normalized tool calls', () => {
- const cases = [
- {
- name: 'string message',
- input: [{
- role: 'assistant',
- content: [
- {
- type: 'tool_use',
- id: 'tool-1',
- name: 'tool-1-function',
- input: {},
- }
- ]
- }],
- output: [{
- role: 'assistant',
- content: null,
- tool_calls: [
- {
- id: 'tool-1',
- type: 'function',
- function: {
- name: 'tool-1-function',
- arguments: '{}',
- }
- }
- ]
- }]
- }
- ];
- for ( const tc of cases ) {
- it(`should normalize ${tc.name}`, async () => {
- const output = await OpenAIUtil.process_input_messages(tc.input);
- expect(output).toEqual(tc.output);
- });
- }
- });
-});
\ No newline at end of file
diff --git a/src/backend/src/modules/puterfs/DatabaseFSEntryFetcher.js b/src/backend/src/modules/puterfs/DatabaseFSEntryFetcher.js
index 0789a6df36..df881265fc 100644
--- a/src/backend/src/modules/puterfs/DatabaseFSEntryFetcher.js
+++ b/src/backend/src/modules/puterfs/DatabaseFSEntryFetcher.js
@@ -222,4 +222,12 @@ module.exports = class DatabaseFSEntryFetcher extends BaseService {
);
return !! check_dupe[0];
}
+
+ async nameExistsUnderParentID (parent_id, name) {
+ const parent = await this.findByID(parent_id);
+ if ( ! parent ) {
+ return false;
+ }
+ return this.nameExistsUnderParent(parent.uuid, name);
+ }
}
diff --git a/src/backend/src/modules/puterfs/MountpointService.js b/src/backend/src/modules/puterfs/MountpointService.js
index 48dddd8ff7..b15c4eb3c8 100644
--- a/src/backend/src/modules/puterfs/MountpointService.js
+++ b/src/backend/src/modules/puterfs/MountpointService.js
@@ -19,7 +19,7 @@
*/
// const Mountpoint = o => ({ ...o });
-const { RootNodeSelector, NodeUIDSelector } = require("../../filesystem/node/selectors");
+const { RootNodeSelector, NodeUIDSelector, NodeChildSelector, NodePathSelector, NodeInternalIDSelector, NodeSelector, try_infer_attributes } = require("../../filesystem/node/selectors");
const BaseService = require("../../services/BaseService");
/**
@@ -57,8 +57,9 @@ class MountpointService extends BaseService {
* @returns {Promise}
*/
async _init () {
- // Temporary solution - we'll develop this incrementally
- this.storage_ = null;
+ // key: provider class (e.g: PuterFSProvider, MemoryFSProvider)
+ // value: storage instance
+ this.storage_ = {};
}
async ['__on_boot.consolidation'] () {
@@ -87,12 +88,37 @@ class MountpointService extends BaseService {
}
async get_provider (selector) {
+ // type check
+ if ( ! (selector instanceof NodeSelector) ) {
+ throw new Error('Invalid selector type');
+ }
+
+ try_infer_attributes(selector);
+
if ( selector instanceof RootNodeSelector ) {
return this.mountpoints_['/'].provider;
}
if ( selector instanceof NodeUIDSelector ) {
- return this.mountpoints_['/'].provider;
+ for ( const [path, { provider }] of Object.entries(this.mountpoints_) ) {
+ const result = await provider.quick_check({
+ selector,
+ });
+ if ( result ) {
+ return provider;
+ }
+ }
+
+ // No provider found, but we shouldn't throw an error here
+ // because it's a valid case for a node that doesn't exist.
+ }
+
+ if ( selector instanceof NodeChildSelector ) {
+ if ( selector.path ) {
+ return this.get_provider(new NodePathSelector(selector.path));
+ } else {
+ return this.get_provider(selector.parent);
+ }
}
const probe = {};
@@ -118,15 +144,16 @@ class MountpointService extends BaseService {
}
// Temporary solution - we'll develop this incrementally
- set_storage (storage) {
- this.storage_ = storage;
+ set_storage (provider, storage) {
+ this.storage_[provider] = storage;
}
+
/**
* Gets the current storage backend instance
* @returns {Object} The storage backend instance
*/
- get_storage () {
- return this.storage_;
+ get_storage (provider) {
+ return this.storage_[provider];
}
}
diff --git a/src/backend/src/modules/puterfs/PuterFSModule.js b/src/backend/src/modules/puterfs/PuterFSModule.js
index 91b88fb52f..234565fdcb 100644
--- a/src/backend/src/modules/puterfs/PuterFSModule.js
+++ b/src/backend/src/modules/puterfs/PuterFSModule.js
@@ -40,6 +40,9 @@ class PuterFSModule extends AdvancedBase {
const DatabaseFSEntryFetcher = require("./DatabaseFSEntryFetcher");
services.registerService('fsEntryFetcher', DatabaseFSEntryFetcher);
+
+ const { MemoryFSService } = require('./customfs/MemoryFSService');
+ services.registerService('memoryfs', MemoryFSService);
}
}
diff --git a/src/backend/src/modules/puterfs/customfs/MemoryFSProvider.js b/src/backend/src/modules/puterfs/customfs/MemoryFSProvider.js
new file mode 100644
index 0000000000..3b1cb6c5e5
--- /dev/null
+++ b/src/backend/src/modules/puterfs/customfs/MemoryFSProvider.js
@@ -0,0 +1,605 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+const FSNodeContext = require('../../../filesystem/FSNodeContext');
+const _path = require('path');
+const { Context } = require('../../../util/context');
+const { v4: uuidv4 } = require('uuid');
+const config = require('../../../config');
+const {
+ try_infer_attributes,
+ NodeChildSelector,
+ NodePathSelector,
+ NodeUIDSelector,
+ NodeSelector,
+} = require('../../../filesystem/node/selectors');
+const fsCapabilities = require('../../../filesystem/definitions/capabilities');
+const APIError = require('../../../api/APIError');
+
+class MemoryFile {
+ /**
+ * @param {Object} param
+ * @param {string} param.path - Relative path from the mountpoint.
+ * @param {boolean} param.is_dir
+ * @param {Buffer|null} param.content - The content of the file, `null` if the file is a directory.
+ * @param {string|null} [param.parent_uid] - UID of parent directory; null for root.
+ */
+ constructor({ path, is_dir, content, parent_uid = null }) {
+ this.uuid = uuidv4();
+
+ this.is_public = true;
+ this.path = path;
+ this.name = _path.basename(path);
+ this.is_dir = is_dir;
+
+ this.content = content;
+
+ // parent_uid should reflect the actual parent's uid; null for root
+ this.parent_uid = parent_uid;
+
+ // TODO (xiaochen): return sensible values for "user_id", currently
+ // it must be 2 (admin) to pass the test.
+ this.user_id = 2;
+
+ // TODO (xiaochen): return sensible values for following fields
+ this.id = 123;
+ this.parent_id = 123;
+ this.immutable = 0;
+ this.is_shortcut = 0;
+ this.is_symlink = 0;
+ this.symlink_path = null;
+ this.created = Math.floor(Date.now() / 1000);
+ this.accessed = Math.floor(Date.now() / 1000);
+ this.modified = Math.floor(Date.now() / 1000);
+ this.size = is_dir ? 0 : content ? content.length : 0;
+ }
+}
+
+class MemoryFSProvider {
+ constructor(mountpoint) {
+ this.mountpoint = mountpoint;
+
+ // key: relative path from the mountpoint, always starts with `/`
+ // value: entry uuid
+ this.entriesByPath = new Map();
+
+ // key: entry uuid
+ // value: entry (MemoryFile)
+ //
+ // We declare 2 maps to support 2 lookup apis: by-path/by-uuid.
+ this.entriesByUUID = new Map();
+
+ const root = new MemoryFile({
+ path: '/',
+ is_dir: true,
+ content: null,
+ parent_uid: null,
+ });
+ this.entriesByPath.set('/', root.uuid);
+ this.entriesByUUID.set(root.uuid, root);
+ }
+
+ /**
+ * Get the capabilities of this filesystem provider.
+ *
+ * @returns {Set} - Set of capabilities supported by this provider.
+ */
+ get_capabilities() {
+ return new Set([
+ fsCapabilities.READDIR_UUID_MODE,
+ fsCapabilities.UUID,
+ fsCapabilities.READ,
+ fsCapabilities.WRITE,
+ fsCapabilities.COPY_TREE,
+ ]);
+ }
+
+ /**
+ * Normalize the path to be relative to the mountpoint. Returns `/` if the path is empty/undefined.
+ *
+ * @param {string} path - The path to normalize.
+ * @returns {string} - The normalized path, always starts with `/`.
+ */
+ _inner_path(path) {
+ if (!path) {
+ return '/';
+ }
+
+ if (path.startsWith(this.mountpoint)) {
+ path = path.slice(this.mountpoint.length);
+ }
+
+ if (!path.startsWith('/')) {
+ path = '/' + path;
+ }
+
+ return path;
+ }
+
+ /**
+ * Check the integrity of the whole memory filesystem. Throws error if any violation is found.
+ *
+ * @returns {Promise}
+ */
+ _integrity_check() {
+ if (config.env !== 'dev') {
+ // only check in debug mode since it's expensive
+ return;
+ }
+
+ // check the 2 maps are consistent
+ if (this.entriesByPath.size !== this.entriesByUUID.size) {
+ throw new Error('Path map and UUID map have different sizes');
+ }
+
+ for (const [inner_path, uuid] of this.entriesByPath) {
+ const entry = this.entriesByUUID.get(uuid);
+
+ // entry should exist
+ if (!entry) {
+ throw new Error(`Entry ${uuid} does not exist`);
+ }
+
+ // path should match
+ if (this._inner_path(entry.path) !== inner_path) {
+ throw new Error(`Path ${inner_path} does not match entry ${uuid}`);
+ }
+
+ // uuid should match
+ if (entry.uuid !== uuid) {
+ throw new Error(`UUID ${uuid} does not match entry ${entry.uuid}`);
+ }
+
+ // parent should exist
+ if (entry.parent_uid) {
+ const parent_entry = this.entriesByUUID.get(entry.parent_uid);
+ if (!parent_entry) {
+ throw new Error(`Parent ${entry.parent_uid} does not exist`);
+ }
+ }
+
+ // parent's path should be a prefix of the entry's path
+ if (entry.parent_uid) {
+ const parent_entry = this.entriesByUUID.get(entry.parent_uid);
+ if (!entry.path.startsWith(parent_entry.path)) {
+ throw new Error(
+ `Parent ${entry.parent_uid} path ${parent_entry.path} is not a prefix of entry ${entry.path}`,
+ );
+ }
+ }
+
+ // parent should be a directory
+ if (entry.parent_uid) {
+ const parent_entry = this.entriesByUUID.get(entry.parent_uid);
+ if (!parent_entry.is_dir) {
+ throw new Error(`Parent ${entry.parent_uid} is not a directory`);
+ }
+ }
+ }
+ }
+
+ /**
+ * Check if a given node exists.
+ *
+ * @param {Object} param
+ * @param {NodeSelector} param.selector - The selector used for checking.
+ * @returns {Promise} - True if the node exists, false otherwise.
+ */
+ async quick_check({ selector }) {
+ // memoryfs relies on attributes path/uid
+ try_infer_attributes(selector);
+
+ // shortcut: has full path
+ if (selector?.path) {
+ const inner_path = this._inner_path(selector.path);
+ return this.entriesByPath.has(inner_path);
+ }
+
+ // shortcut: has uid
+ if (selector?.uid) {
+ return this.entriesByUUID.has(selector.uid);
+ }
+
+ if (selector instanceof NodeChildSelector) {
+ let parent_entry = null;
+ if (selector.parent instanceof NodeUIDSelector) {
+ // shortcut: uid parent + name child
+ parent_entry = this.entriesByUUID.get(selector.parent.uid);
+ } else {
+ // composite parent, has to stat it first
+ parent_entry = await this.stat({
+ selector: selector.parent,
+ });
+ }
+
+ if (parent_entry) {
+ const full_path = _path.join(parent_entry.path, selector.name);
+ const path_selector = new NodePathSelector(full_path);
+ return this.quick_check({
+ selector: path_selector,
+ });
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Performs a stat operation using the given selector.
+ *
+ * NB: Some returned fields currently contain placeholder values. And the
+ * `path` of the absolute path from the root.
+ *
+ * @param {Object} param
+ * @param {NodeSelector} param.selector - The selector to stat.
+ * @returns {Promise} - The result of the stat operation, or `null` if the node doesn't exist.
+ */
+ async stat({ selector }) {
+ // memoryfs relies on attributes path/uid
+ try_infer_attributes(selector);
+
+ let entry_uuid = null;
+
+ if (selector?.path) {
+ // stat by path
+ const inner_path = this._inner_path(selector.path);
+ entry_uuid = this.entriesByPath.get(inner_path);
+ } else if (selector?.uid) {
+ // stat by uid
+ entry_uuid = selector.uid;
+ } else {
+ // the tricky case: selector has a composite (i.e. non path/uid) parent
+ if (selector instanceof NodeChildSelector) {
+ const parent_entry = await this.stat({
+ selector: selector.parent,
+ });
+ if (parent_entry) {
+ const full_path = _path.join(parent_entry.path, selector.name);
+ const path_selector = new NodePathSelector(full_path);
+ return await this.stat({
+ selector: path_selector,
+ });
+ }
+ }
+
+ throw APIError.create('invalid_node');
+ }
+
+ const entry = this.entriesByUUID.get(entry_uuid);
+ if ( ! entry ) {
+ return null;
+ }
+
+ // Return a copied entry with `full_path`, since external code only cares
+ // about full path.
+ const copied_entry = { ...entry };
+ copied_entry.path = _path.join(this.mountpoint, entry.path);
+ return copied_entry;
+ }
+
+ /**
+ * Read directory contents.
+ *
+ * @param {Object} param
+ * @param {Context} param.context - The context of the operation.
+ * @param {FSNodeContext} param.node - The directory node to read.
+ * @returns {Promise} - Array of child UUIDs.
+ */
+ async readdir({ context, node }) {
+ // prerequistes: get required path via stat
+ const entry = await this.stat({ selector: node.selector });
+ if ( ! entry ) {
+ throw APIError.create('invalid_node');
+ }
+
+ const inner_path = this._inner_path(entry.path);
+ const child_uuids = [];
+
+ // Find all entries that are direct children of this directory
+ for (const [path, uuid] of this.entriesByPath) {
+ if (path === inner_path) {
+ continue; // Skip the directory itself
+ }
+
+ const dirname = _path.dirname(path);
+ if (dirname === inner_path) {
+ child_uuids.push(uuid);
+ }
+ }
+
+ return child_uuids;
+ }
+
+ /**
+ * Create a new directory.
+ *
+ * @param {Object} param
+ * @param {Context} param.context - The context of the operation.
+ * @param {FSNodeContext} param.parent - The parent node to create the directory in. Must exist and be a directory.
+ * @param {string} param.name - The name of the new directory.
+ * @returns {Promise} - The new directory node.
+ */
+ async mkdir({ context, parent, name }) {
+ // prerequistes: get required path via stat
+ const parent_entry = await this.stat({ selector: parent.selector });
+ if ( ! parent_entry ) {
+ throw APIError.create('invalid_node');
+ }
+
+ const full_path = _path.join(parent_entry.path, name);
+ const inner_path = this._inner_path(full_path);
+
+ const entry = new MemoryFile({
+ path: inner_path,
+ is_dir: true,
+ content: null,
+ parent_uid: parent_entry.uuid,
+ });
+ this.entriesByPath.set(inner_path, entry.uuid);
+ this.entriesByUUID.set(entry.uuid, entry);
+
+ // create the node
+ const fs = context.get('services').get('filesystem');
+ const node = await fs.node(entry.uuid);
+ await node.fetchEntry();
+
+ this._integrity_check();
+
+ return node;
+ }
+
+ /**
+ * Remove a directory.
+ *
+ * @param {Object} param
+ * @param {Context} param.context
+ * @param {FSNodeContext} param.node: The directory to remove.
+ * @param {Object} param.options: The options for the operation.
+ * @returns {Promise}
+ */
+ async rmdir({ context, node, options = {} }) {
+ // prerequistes: get required path via stat
+ const entry = await this.stat({ selector: node.selector });
+ if ( ! entry ) {
+ throw APIError.create('invalid_node');
+ }
+
+ const inner_path = this._inner_path(entry.path);
+
+ // for mode: non-recursive
+ if (!options.recursive) {
+ const children = await this.readdir({ context, node });
+ if (children.length > 0) {
+ throw APIError.create('not_empty');
+ }
+ }
+
+ // remove all descendants
+ for (const [other_inner_path, other_entry_uuid] of this.entriesByPath) {
+ if (other_entry_uuid === entry.uuid) {
+ // skip the directory itself
+ continue;
+ }
+
+ if (other_inner_path.startsWith(inner_path)) {
+ this.entriesByPath.delete(other_inner_path);
+ this.entriesByUUID.delete(other_entry_uuid);
+ }
+ }
+
+ // for mode: non-descendants-only
+ if (!options.descendants_only) {
+ // remove the directory itself
+ this.entriesByPath.delete(inner_path);
+ this.entriesByUUID.delete(entry.uuid);
+ }
+
+ this._integrity_check();
+ }
+
+ /**
+ * Remove a file.
+ *
+ * @param {Object} param
+ * @param {Context} param.context
+ * @param {FSNodeContext} param.node: The file to remove.
+ * @returns {Promise}
+ */
+ async unlink({ context, node }) {
+ // prerequistes: get required path via stat
+ const entry = await this.stat({ selector: node.selector });
+ if ( ! entry ) {
+ throw APIError.create('invalid_node');
+ }
+
+ const inner_path = this._inner_path(entry.path);
+ this.entriesByPath.delete(inner_path);
+ this.entriesByUUID.delete(entry.uuid);
+ }
+
+ /**
+ * Move a file.
+ *
+ * @param {Object} param
+ * @param {Context} param.context
+ * @param {FSNodeContext} param.node: The file to move.
+ * @param {FSNodeContext} param.new_parent: The new parent directory of the file.
+ * @param {string} param.new_name: The new name of the file.
+ * @param {Object} param.metadata: The metadata of the file.
+ * @returns {Promise}
+ */
+ async move({ context, node, new_parent, new_name, metadata }) {
+ // prerequistes: get required path via stat
+ const new_parent_entry = await this.stat({ selector: new_parent.selector });
+ if ( ! new_parent_entry ) {
+ throw APIError.create('invalid_node');
+ }
+
+ // create the new entry
+ const new_full_path = _path.join(new_parent_entry.path, new_name);
+ const new_inner_path = this._inner_path(new_full_path);
+ const entry = new MemoryFile({
+ path: new_inner_path,
+ is_dir: node.entry.is_dir,
+ content: node.entry.content,
+ parent_uid: new_parent_entry.uuid,
+ });
+ entry.uuid = node.entry.uuid;
+ this.entriesByPath.set(new_inner_path, entry.uuid);
+ this.entriesByUUID.set(entry.uuid, entry);
+
+ // remove the old entry
+ const inner_path = this._inner_path(node.path);
+ this.entriesByPath.delete(inner_path);
+ // NB: should not delete the entry by uuid because uuid does not change
+ // after the move.
+
+ this._integrity_check();
+
+ return entry;
+ }
+
+ /**
+ * Copy a tree of files and directories.
+ *
+ * @param {Object} param
+ * @param {Context} param.context
+ * @param {FSNodeContext} param.source - The source node to copy.
+ * @param {FSNodeContext} param.parent - The parent directory for the copy.
+ * @param {string} param.target_name - The name for the copied item.
+ * @returns {Promise} - The copied node.
+ */
+ async copy_tree({ context, source, parent, target_name }) {
+ const fs = context.get('services').get('filesystem');
+
+ if (source.entry.is_dir) {
+ // Create the directory
+ const new_dir = await this.mkdir({ context, parent, name: target_name });
+
+ // Copy all children
+ const children = await this.readdir({ context, node: source });
+ for (const child_uuid of children) {
+ const child_node = await fs.node(new NodeUIDSelector(child_uuid));
+ await child_node.fetchEntry();
+ const child_name = child_node.entry.name;
+
+ await this.copy_tree({
+ context,
+ source: child_node,
+ parent: new_dir,
+ target_name: child_name,
+ });
+ }
+
+ return new_dir;
+ } else {
+ // Copy the file
+ const new_file = await this.write_new({
+ context,
+ parent,
+ name: target_name,
+ file: { stream: { read: () => source.entry.content } },
+ });
+ return new_file;
+ }
+ }
+
+ /**
+ * Write a new file to the filesystem. Throws an error if the destination
+ * already exists.
+ *
+ * @param {Object} param
+ * @param {Context} param.context
+ * @param {FSNodeContext} param.parent: The parent directory of the destination directory.
+ * @param {string} param.name: The name of the destination directory.
+ * @param {Object} param.file: The file to write.
+ * @returns {Promise}
+ */
+ async write_new({ context, parent, name, file }) {
+ // prerequistes: get required path via stat
+ const parent_entry = await this.stat({ selector: parent.selector });
+ if ( ! parent_entry ) {
+ throw APIError.create('invalid_node');
+ }
+ const full_path = _path.join(parent_entry.path, name);
+ const inner_path = this._inner_path(full_path);
+
+ const entry = new MemoryFile({
+ path: inner_path,
+ is_dir: false,
+ content: file.stream.read(),
+ parent_uid: parent_entry.uuid,
+ });
+ this.entriesByPath.set(inner_path, entry.uuid);
+ this.entriesByUUID.set(entry.uuid, entry);
+
+ const fs = context.get('services').get('filesystem');
+ const node = await fs.node(entry.uuid);
+ await node.fetchEntry();
+
+ this._integrity_check();
+
+ return node;
+ }
+
+ /**
+ * Overwrite an existing file. Throws an error if the destination does not
+ * exist.
+ *
+ * @param {Object} param
+ * @param {Context} param.context
+ * @param {FSNodeContext} param.node: The node to write to.
+ * @param {Object} param.file: The file to write.
+ * @returns {Promise}
+ */
+ async write_overwrite({ context, node, file }) {
+ const entry = await this.stat({ selector: node.selector });
+ if ( ! entry ) {
+ throw APIError.create('invalid_node');
+ }
+ const inner_path = this._inner_path(entry.path);
+
+ this.entriesByPath.set(inner_path, entry.uuid);
+ let original_entry = this.entriesByUUID.get(entry.uuid);
+ if (!original_entry) {
+ throw new Error(`File ${entry.path} does not exist`);
+ } else {
+ if (original_entry.is_dir) {
+ throw new Error(`Cannot overwrite a directory`);
+ }
+
+ original_entry.content = file.stream.read();
+ original_entry.modified = Math.floor(Date.now() / 1000);
+ original_entry.size = original_entry.content ? original_entry.content.length : 0;
+ this.entriesByUUID.set(entry.uuid, original_entry);
+ }
+
+ const fs = context.get('services').get('filesystem');
+ node = await fs.node(original_entry.uuid);
+ await node.fetchEntry();
+
+ this._integrity_check();
+
+ return node;
+ }
+}
+
+module.exports = {
+ MemoryFSProvider,
+};
diff --git a/src/backend/src/modules/puterfs/customfs/MemoryFSService.js b/src/backend/src/modules/puterfs/customfs/MemoryFSService.js
new file mode 100644
index 0000000000..99397ecfbd
--- /dev/null
+++ b/src/backend/src/modules/puterfs/customfs/MemoryFSService.js
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+const BaseService = require("../../../services/BaseService");
+const { MemoryFSProvider } = require("./MemoryFSProvider");
+
+class MemoryFSService extends BaseService {
+ async _init () {
+ const svc_mountpoint = this.services.get('mountpoint');
+ svc_mountpoint.register_mounter('memoryfs', this.as('mounter'));
+ }
+
+ static IMPLEMENTS = {
+ mounter: {
+ async mount ({ path, options }) {
+ const provider = new MemoryFSProvider(path);
+ return provider;
+ }
+ }
+ }
+}
+
+module.exports = {
+ MemoryFSService,
+};
\ No newline at end of file
diff --git a/src/backend/src/modules/puterfs/customfs/README.md b/src/backend/src/modules/puterfs/customfs/README.md
new file mode 100644
index 0000000000..bd66e79e31
--- /dev/null
+++ b/src/backend/src/modules/puterfs/customfs/README.md
@@ -0,0 +1,15 @@
+# Custom FS Providers
+
+This directory contains custom FS providers that are not part of the core PuterFS.
+
+## MemoryFSProvider
+
+This is a demo FS provider that illustrates how to implement a custom FS provider.
+
+## NullFSProvider
+
+A FS provider that mimics `/dev/null`.
+
+## LinuxFSProvider
+
+Provide the ability to mount a Linux directory as a FS provider.
\ No newline at end of file
diff --git a/src/backend/src/modules/puterfs/lib/PuterFSProvider.js b/src/backend/src/modules/puterfs/lib/PuterFSProvider.js
index 0f7ce05182..449ca41e2e 100644
--- a/src/backend/src/modules/puterfs/lib/PuterFSProvider.js
+++ b/src/backend/src/modules/puterfs/lib/PuterFSProvider.js
@@ -21,7 +21,7 @@ const putility = require('@heyputer/putility');
const { MultiDetachable } = putility.libs.listener;
const { TDetachable } = putility.traits;
-const { NodeInternalIDSelector, NodeChildSelector, NodeUIDSelector, RootNodeSelector, NodePathSelector } = require("../../../filesystem/node/selectors");
+const { NodeInternalIDSelector, NodeChildSelector, NodeUIDSelector, RootNodeSelector, NodePathSelector, NodeSelector } = require("../../../filesystem/node/selectors");
const { Context } = require("../../../util/context");
const fsCapabilities = require('../../../filesystem/definitions/capabilities');
const { UploadProgressTracker } = require('../../../filesystem/storage/UploadProgressTracker');
@@ -31,11 +31,21 @@ const { ParallelTasks } = require('../../../util/otelutil');
const { TYPE_DIRECTORY } = require('../../../filesystem/FSNodeContext');
const APIError = require('../../../api/APIError');
+const { MODE_WRITE } = require('../../../services/fs/FSLockService');
+const { DB_WRITE } = require('../../../services/database/consts');
+const { stuck_detector_stream, hashing_stream } = require('../../../util/streamutil');
+
+const crypto = require('crypto');
+const { OperationFrame } = require('../../../services/OperationTraceService');
+
+const STUCK_STATUS_TIMEOUT = 10 * 1000;
+const STUCK_ALARM_TIMEOUT = 20 * 1000;
class PuterFSProvider extends putility.AdvancedBase {
static MODULES = {
_path: require('path'),
uuidv4: require('uuid').v4,
+ config: require('../../../config.js'),
}
get_capabilities () {
@@ -55,6 +65,52 @@ class PuterFSProvider extends putility.AdvancedBase {
]);
}
+ /**
+ * Check if a given node exists.
+ *
+ * @param {Object} param
+ * @param {NodeSelector} param.selector - The selector used for checking.
+ * @returns {Promise} - True if the node exists, false otherwise.
+ */
+ async quick_check ({
+ selector,
+ }) {
+ // a wrapper that access underlying database directly
+ const fsEntryFetcher = Context.get('services').get('fsEntryFetcher');
+
+ // shortcut: has full path
+ if ( selector?.path ) {
+ const entry = await fsEntryFetcher.findByPath(selector.path);
+ return Boolean(entry);
+ }
+
+ // shortcut: has uid
+ if ( selector?.uid ) {
+ const entry = await fsEntryFetcher.findByUID(selector.uid);
+ return Boolean(entry);
+ }
+
+ // shortcut: parent uid + child name
+ if ( selector instanceof NodeChildSelector && selector.parent instanceof NodeUIDSelector ) {
+ return await fsEntryFetcher.nameExistsUnderParent(
+ selector.parent.uid,
+ selector.name,
+ );
+ }
+
+ // shortcut: parent id + child name
+ if ( selector instanceof NodeChildSelector && selector.parent instanceof NodeInternalIDSelector ) {
+ return await fsEntryFetcher.nameExistsUnderParentID(
+ selector.parent.id,
+ selector.name,
+ );
+ }
+
+ // TODO (xiaochen): we should fallback to stat but we cannot at this moment
+ // since stat requires a valid `FSNodeContext` argument.
+ return false;
+ }
+
async stat ({
selector,
options,
@@ -429,6 +485,416 @@ class PuterFSProvider extends putility.AdvancedBase {
await tasks.awaitAll();
}
+
+ /**
+ * Create a new directory.
+ *
+ * @param {Object} param
+ * @param {Context} param.context
+ * @param {FSNode} param.parent
+ * @param {string} param.name
+ * @param {boolean} param.immutable
+ * @returns {Promise}
+ */
+ async mkdir({ context, parent, name, immutable}) {
+ const { actor, thumbnail } = context.values;
+
+ const svc_fslock = context.get('services').get('fslock');
+ const lock_handle = await svc_fslock.lock_child(
+ await parent.get('path'),
+ name,
+ MODE_WRITE,
+ );
+
+ try {
+ const { _path, uuidv4 } = this.modules;
+
+ const ts = Math.round(Date.now() / 1000);
+ const uid = uuidv4();
+ const resourceService = context.get('services').get('resourceService');
+ const svc_fsEntry = context.get('services').get('fsEntryService');
+ const svc_event = context.get('services').get('event');
+ const fs = context.get('services').get('filesystem');
+
+ const existing = await fs.node(
+ new NodeChildSelector(parent.selector, name)
+ );
+
+ if (await existing.exists()) {
+ throw APIError.create('item_with_same_name_exists', null, {
+ entry_name: name,
+ });
+ }
+
+ const svc_acl = context.get('services').get('acl');
+ if (! await parent.exists()) {
+ throw APIError.create('subject_does_not_exist');
+ }
+ if (! await svc_acl.check(actor, parent, 'write')) {
+ throw await svc_acl.get_safe_acl_error(actor, parent, 'write');
+ }
+
+ resourceService.register({
+ uid,
+ status: RESOURCE_STATUS_PENDING_CREATE,
+ });
+
+ const raw_fsentry = {
+ is_dir: 1,
+ uuid: uid,
+ parent_uid: await parent.get('uid'),
+ path: _path.join(await parent.get('path'), name),
+ user_id: actor.type.user.id,
+ name,
+ created: ts,
+ accessed: ts,
+ modified: ts,
+ immutable: immutable ?? false,
+ ...(thumbnail ? {
+ thumbnail: thumbnail,
+ } : {}),
+ };
+
+ const entryOp = await svc_fsEntry.insert(raw_fsentry);
+
+ await entryOp.awaitDone();
+ resourceService.free(uid);
+
+ const node = await fs.node(new NodeUIDSelector(uid));
+
+ svc_event.emit('fs.create.directory', {
+ node,
+ context: Context.get(),
+ });
+
+ return node
+ } finally {
+ await lock_handle.unlock();
+ }
+ }
+
+ /**
+ * Write a new file to the filesystem. Throws an error if the destination
+ * already exists.
+ *
+ * @param {Object} param
+ * @param {Context} param.context
+ * @param {FSNode} param.parent: The parent directory of the file.
+ * @param {string} param.name: The name of the file.
+ * @param {File} param.file: The file to write.
+ * @returns {Promise}
+ */
+ async write_new({context, parent, name, file}) {
+ const { _path, uuidv4, config } = this.modules;
+
+ const {
+ tmp, fsentry_tmp, message, actor: actor_let, app_id,
+ } = context.values;
+ let actor = actor_let ?? Context.get('actor');
+
+ const svc = Context.get('services');
+ const sizeService = svc.get('sizeService');
+ const resourceService = svc.get('resourceService');
+ const svc_fsEntry = svc.get('fsEntryService');
+ const svc_event = svc.get('event');
+ const fs = svc.get('filesystem');
+
+ // TODO: fs:decouple-versions
+ // add version hook externally so LLCWrite doesn't
+ // need direct database access
+ const db = svc.get('database').get(DB_WRITE, 'filesystem');
+
+ const uid = uuidv4();
+
+ // determine bucket region
+ let bucket_region = config.s3_region ?? config.region;
+ let bucket = config.s3_bucket;
+
+ const svc_acl = context.get('services').get('acl');
+ if ( ! await svc_acl.check(actor, parent, 'write') ) {
+ throw await svc_acl.get_safe_acl_error(actor, parent, 'write');
+ }
+
+ const storage_resp = await this._storage_upload({
+ uuid: uid,
+ bucket, bucket_region, file,
+ tmp: {
+ ...tmp,
+ path: _path.join(await parent.get('path'), name),
+ }
+ });
+
+ fsentry_tmp.thumbnail = await fsentry_tmp.thumbnail_promise;
+ delete fsentry_tmp.thumbnail_promise;
+
+ const ts = Math.round(Date.now() / 1000);
+ const raw_fsentry = {
+ uuid: uid,
+ is_dir: 0,
+ user_id: actor.type.user.id,
+ created: ts,
+ accessed: ts,
+ modified: ts,
+ parent_uid: await parent.get('uid'),
+ name,
+ size: file.size,
+ path: _path.join(await parent.get('path'), name),
+ ...fsentry_tmp,
+
+ bucket_region,
+ bucket,
+
+ associated_app_id: app_id ?? null,
+ };
+
+ svc_event.emit('fs.pending.file', {
+ fsentry: FSNodeContext.sanitize_pending_entry_info(raw_fsentry),
+ context,
+ })
+
+ resourceService.register({
+ uid,
+ status: RESOURCE_STATUS_PENDING_CREATE,
+ });
+
+ const filesize = file.size;
+ sizeService.change_usage(actor.type.user.id, filesize);
+
+ const entryOp = await svc_fsEntry.insert(raw_fsentry);
+
+ (async () => {
+ await entryOp.awaitDone();
+ resourceService.free(uid);
+
+ const new_item_node = await fs.node(new NodeUIDSelector(uid));
+ const new_item = await new_item_node.get('entry');
+ const store_version_id = storage_resp.VersionId;
+ if( store_version_id ){
+ // insert version into db
+ db.write(
+ "INSERT INTO `fsentry_versions` (`user_id`, `fsentry_id`, `fsentry_uuid`, `version_id`, `message`, `ts_epoch`) VALUES (?, ?, ?, ?, ?, ?)",
+ [
+ actor.type.user.id,
+ new_item.id,
+ new_item.uuid,
+ store_version_id,
+ message ?? null,
+ ts,
+ ]
+ );
+ }
+ })();
+
+ const node = await fs.node(new NodeUIDSelector(uid));
+
+ svc_event.emit('fs.create.file', {
+ node,
+ context,
+ });
+
+ return node;
+ }
+
+ /**
+ * Overwrite an existing file. Throws an error if the destination does not
+ * exist.
+ *
+ * @param {Object} param
+ * @param {Context} param.context
+ * @param {FSNodeContext} param.node: The node to write to.
+ * @param {File} param.file: The file to write.
+ * @returns {Promise}
+ */
+ async write_overwrite({ context, node, file }) {
+ const {
+ tmp, fsentry_tmp, message, actor: actor_let
+ } = context.values;
+ let actor = actor_let ?? Context.get('actor');
+
+ const svc = Context.get('services');
+ const sizeService = svc.get('sizeService');
+ const resourceService = svc.get('resourceService');
+ const svc_fsEntry = svc.get('fsEntryService');
+ const svc_event = svc.get('event');
+
+ // TODO: fs:decouple-versions
+ // add version hook externally so LLCWrite doesn't
+ // need direct database access
+ const db = svc.get('database').get(DB_WRITE, 'filesystem');
+
+ const svc_acl = context.get('services').get('acl');
+ if ( ! await svc_acl.check(actor, node, 'write') ) {
+ throw await svc_acl.get_safe_acl_error(actor, node, 'write');
+ }
+
+ const uid = await node.get('uid');
+
+ const bucket_region = node.entry.bucket_region;
+ const bucket = node.entry.bucket;
+
+ const state_upload = await this._storage_upload({
+ uuid: node.entry.uuid,
+ bucket, bucket_region, file,
+ tmp: {
+ ...tmp,
+ path: await node.get('path'),
+ }
+ });
+
+ if ( fsentry_tmp?.thumbnail_promise ) {
+ fsentry_tmp.thumbnail = await fsentry_tmp.thumbnail_promise;
+ delete fsentry_tmp.thumbnail_promise;
+ }
+
+ const ts = Math.round(Date.now() / 1000);
+ const raw_fsentry_delta = {
+ modified: ts,
+ accessed: ts,
+ size: file.size,
+ ...fsentry_tmp,
+ };
+
+ resourceService.register({
+ uid,
+ status: RESOURCE_STATUS_PENDING_CREATE,
+ });
+
+ const filesize = file.size;
+ sizeService.change_usage(actor.type.user.id, filesize);
+
+ const entryOp = await svc_fsEntry.update(uid, raw_fsentry_delta);
+
+ // depends on fsentry, does not depend on S3
+ (async () => {
+ await entryOp.awaitDone();
+ resourceService.free(uid);
+ svc_event.emit('fs.written.file', {
+ node,
+ context: this.context,
+ });
+ })();
+
+ state_upload.post_insert({
+ db, user: actor.type.user, node, uid, message, ts,
+ });
+
+ const svc_fileCache = context.get('services').get('file-cache');
+ await svc_fileCache.invalidate(node);
+
+ svc_event.emit('fs.write.file', {
+ node,
+ context,
+ });
+
+ return node;
+ }
+
+
+ async _storage_upload ({
+ uuid,
+ bucket, bucket_region, file,
+ tmp,
+ }) {
+ const { config } = this.modules;
+
+ const svc = Context.get('services');
+ const log = svc.get('log-service').create('fs._storage_upload');
+ const errors = svc.get('error-service').create(log);
+ const svc_event = svc.get('event');
+
+ const svc_mountpoint = svc.get('mountpoint');
+ const storage = svc_mountpoint.get_storage(this.constructor);
+
+ bucket ??= config.s3_bucket;
+ bucket_region ??= config.s3_region ?? config.region;
+
+ let upload_tracker = new UploadProgressTracker();
+
+ svc_event.emit('fs.storage.upload-progress', {
+ upload_tracker,
+ context: Context.get(),
+ meta: {
+ item_uid: uuid,
+ item_path: tmp.path,
+ }
+ })
+
+ if ( ! file.buffer ) {
+ let stream = file.stream;
+ let alarm_timeout = null;
+ stream = stuck_detector_stream(stream, {
+ timeout: STUCK_STATUS_TIMEOUT,
+ on_stuck: () => {
+ this.frame.status = OperationFrame.FRAME_STATUS_STUCK;
+ log.warn('Upload stream stuck might be stuck', {
+ bucket_region,
+ bucket,
+ uuid,
+ });
+ alarm_timeout = setTimeout(() => {
+ errors.report('fs.write.s3-upload', {
+ message: 'Upload stream stuck for too long',
+ alarm: true,
+ extra: {
+ bucket_region,
+ bucket,
+ uuid,
+ },
+ });
+ }, STUCK_ALARM_TIMEOUT);
+ },
+ on_unstuck: () => {
+ clearTimeout(alarm_timeout);
+ this.frame.status = OperationFrame.FRAME_STATUS_WORKING;
+ }
+ });
+ file = { ...file, stream, };
+ }
+
+ let hashPromise;
+ if ( file.buffer ) {
+ const hash = crypto.createHash('sha256');
+ hash.update(file.buffer);
+ hashPromise = Promise.resolve(hash.digest('hex'));
+ } else {
+ const hs = hashing_stream(file.stream);
+ file.stream = hs.stream;
+ hashPromise = hs.hashPromise;
+ }
+
+ hashPromise.then(hash => {
+ const svc_event = Context.get('services').get('event');
+ console.log('\x1B[36;1m[fs.write]', uuid, hash);
+ svc_event.emit('outer.fs.write-hash', {
+ hash, uuid,
+ });
+ });
+
+ const state_upload = storage.create_upload();
+
+ try {
+ await state_upload.run({
+ uid: uuid,
+ file,
+ storage_meta: { bucket, bucket_region },
+ storage_api: { progress_tracker: upload_tracker },
+ });
+ } catch (e) {
+ errors.report('fs.write.storage-upload', {
+ source: e || new Error('unknown'),
+ trace: true,
+ alarm: true,
+ extra: {
+ bucket_region,
+ bucket,
+ uuid,
+ },
+ });
+ throw APIError.create('upload_failed');
+ }
+
+ return state_upload;
+ }
}
module.exports = {
diff --git a/src/backend/src/modules/selfhosted/DefaultUserService.js b/src/backend/src/modules/selfhosted/DefaultUserService.js
index ad4f641385..b5c633684f 100644
--- a/src/backend/src/modules/selfhosted/DefaultUserService.js
+++ b/src/backend/src/modules/selfhosted/DefaultUserService.js
@@ -89,6 +89,8 @@ class DefaultUserService extends BaseService {
);
if ( ! is_default_password ) return;
+ console.log(`password for admin is: ${tmp_password}`);
+
// show console widget
this.default_user_widget = ({ is_docker }) => {
if ( is_docker ) {
diff --git a/src/backend/src/modules/web/WebServerService.js b/src/backend/src/modules/web/WebServerService.js
index cb319b58ba..4c4fcfa626 100644
--- a/src/backend/src/modules/web/WebServerService.js
+++ b/src/backend/src/modules/web/WebServerService.js
@@ -612,7 +612,7 @@ class WebServerService extends BaseService {
req.co_isolation_enabled
;
- if ( req.path === '/signup' || req.path === '/login' ) {
+ if ( req.path === '/signup' || req.path === '/login' || req.path.startsWith('/extensions/') ) {
res.setHeader('Access-Control-Allow-Origin', origin ?? '*');
}
// Website(s) to allow to connect
diff --git a/src/backend/src/modules/web/lib/eggspress.js b/src/backend/src/modules/web/lib/eggspress.js
index 95aa24e9ab..0567d8fdf4 100644
--- a/src/backend/src/modules/web/lib/eggspress.js
+++ b/src/backend/src/modules/web/lib/eggspress.js
@@ -24,6 +24,7 @@ const api_error_handler = require('./api_error_handler.js');
const APIError = require('../../../api/APIError.js');
const { Context } = require('../../../util/context.js');
const { subdomain } = require('../../../helpers.js');
+const config = require('../../../config.js');
/**
* eggspress() is a factory function for creating express routers.
@@ -169,6 +170,9 @@ module.exports = function eggspress (route, settings, handler) {
return next();
}
}
+ if ( config.env === 'dev' ) {
+ console.log(`request url: ${req.url}, body: ${JSON.stringify(req.body)}`);
+ }
try {
const expected_ctx = res.locals.ctx;
const received_ctx = Context.get(undefined, { allow_fallback: true });
@@ -179,6 +183,13 @@ module.exports = function eggspress (route, settings, handler) {
});
} else await handler(req, res, next);
} catch (e) {
+ if ( config.env === 'dev' ) {
+ if (! (e instanceof APIError)) {
+ // Any non-APIError indicates an unhandled error (i.e. a bug) from the backend.
+ // We add a dedicated branch to facilitate debugging.
+ console.error(e);
+ }
+ }
api_error_handler(e, req, res, next);
}
};
diff --git a/src/backend/src/om/IdentifierUtil.js b/src/backend/src/om/IdentifierUtil.js
index fcecbce6c8..58b8e05d19 100644
--- a/src/backend/src/om/IdentifierUtil.js
+++ b/src/backend/src/om/IdentifierUtil.js
@@ -26,7 +26,7 @@ class IdentifierUtil extends AdvancedBase {
new WeakConstructorFeature(),
]
- async detect_identifier (object) {
+ async detect_identifier (object, allow_mutation = false) {
const redundant_identifiers = this.om.redundant_identifiers ?? [];
let match_found = null;
@@ -60,9 +60,9 @@ class IdentifierUtil extends AdvancedBase {
await object.get(key) : object[key],
}));
if ( object instanceof Entity ) {
- await object.del(key);
+ if ( allow_mutation ) await object.del(key);
} else {
- delete object[key];
+ if ( allow_mutation ) delete object[key];
}
}
let predicate = new And({ children: key_eqs });
diff --git a/src/backend/src/om/entitystorage/SQLES.js b/src/backend/src/om/entitystorage/SQLES.js
index 8af09a66de..32b5cd52d8 100644
--- a/src/backend/src/om/entitystorage/SQLES.js
+++ b/src/backend/src/om/entitystorage/SQLES.js
@@ -22,7 +22,7 @@ const { BaseES } = require("./BaseES");
const APIError = require("../../api/APIError");
const { Entity } = require("./Entity");
const { WeakConstructorFeature } = require("../../traits/WeakConstructorFeature");
-const { And, Or, Eq, Like, Null, Predicate, PredicateUtil, IsNotNull } = require("../query/query");
+const { And, Or, Eq, Like, Null, Predicate, PredicateUtil, IsNotNull, StartsWith } = require("../query/query");
const { DB_WRITE } = require("../../services/database/consts");
class RawCondition extends AdvancedBase {
@@ -400,6 +400,25 @@ class SQLES extends BaseES {
return new RawCondition({ sql, values });
}
+
+ if (om_query instanceof StartsWith) {
+ const key = om_query.key;
+ let value = om_query.value;
+ const prop = this.om.properties[key];
+
+ value = await prop.sql_reference(value);
+
+ const options = prop.descriptor.sql ?? {};
+ const col_name = options.column_name ?? prop.name;
+
+ const sql = `${col_name} LIKE ${this.db.case({
+ sqlite: `? || '%'`,
+ otherwise: `CONCAT(?, '%')`
+ })}`;
+ const values = value === null ? [] : [value];
+
+ return new RawCondition({ sql, values });
+ }
if ( om_query instanceof IsNotNull ) {
const key = om_query.key;
diff --git a/src/backend/src/om/entitystorage/SubdomainES.js b/src/backend/src/om/entitystorage/SubdomainES.js
index 4a063f582d..45cc9473fb 100644
--- a/src/backend/src/om/entitystorage/SubdomainES.js
+++ b/src/backend/src/om/entitystorage/SubdomainES.js
@@ -24,6 +24,8 @@ const { Context } = require("../../util/context");
const { Eq } = require("../query/query");
const { BaseES } = require("./BaseES");
+const PERM_READ_ALL_SUBDOMAINS = 'read-all-subdomains';
+
class SubdomainES extends BaseES {
static METHODS = {
async _on_context_provided () {
@@ -52,12 +54,17 @@ class SubdomainES extends BaseES {
// Note: we don't need to worry about read;
// non-owner users don't have permission to list
// but they still have permission to read.
- options.predicate = options.predicate.and(
- new Eq({
- key: 'owner',
- value: user.id,
- }),
- );
+ const svc_permission = this.context.get('services').get('permission');
+ const has_permission_to_read_all = await svc_permission.check(Context.get("actor"), PERM_READ_ALL_SUBDOMAINS);
+
+ if (!has_permission_to_read_all) {
+ options.predicate = options.predicate.and(
+ new Eq({
+ key: 'owner',
+ value: user.id,
+ }),
+ );
+ }
return await this.upstream.select(options);
},
diff --git a/src/backend/src/om/entitystorage/ValidationES.js b/src/backend/src/om/entitystorage/ValidationES.js
index 5c70d69942..94cee80b13 100644
--- a/src/backend/src/om/entitystorage/ValidationES.js
+++ b/src/backend/src/om/entitystorage/ValidationES.js
@@ -19,6 +19,8 @@
const { BaseES } = require("./BaseES");
const APIError = require("../../api/APIError");
+const { Context } = require("../../util/context");
+const { SKIP_ES_VALIDATION } = require("./consts");
class ValidationES extends BaseES {
async _on_context_provided () {
@@ -61,7 +63,7 @@ class ValidationES extends BaseES {
return await out_entity.get_client_safe();
},
async validate_ (entity, diff) {
- const processed = {};
+ if ( Context.get(SKIP_ES_VALIDATION) ) return;
for ( const prop of Object.values(this.om.properties) ) {
let value = await entity.get(prop.name);
@@ -96,8 +98,6 @@ class ValidationES extends BaseES {
}
throw e;
}
-
- processed[prop.name] = value;
}
},
diff --git a/src/backend/src/om/entitystorage/consts.js b/src/backend/src/om/entitystorage/consts.js
new file mode 100644
index 0000000000..1dade2a72e
--- /dev/null
+++ b/src/backend/src/om/entitystorage/consts.js
@@ -0,0 +1,3 @@
+module.exports = {
+ SKIP_ES_VALIDATION: Symbol('SKIP_ES_VALIDATION'),
+};
diff --git a/src/backend/src/om/query/query.js b/src/backend/src/om/query/query.js
index 4bffcdba67..96d2ea666c 100644
--- a/src/backend/src/om/query/query.js
+++ b/src/backend/src/om/query/query.js
@@ -50,6 +50,12 @@ class Eq extends Predicate {
}
}
+class StartsWith extends Predicate {
+ async check(entity) {
+ return (await entity.get(this.key)).startsWith(this.value);
+ }
+}
+
class IsNotNull extends Predicate {
async check (entity) {
return (await entity.get(this.key)) !== null;
@@ -122,4 +128,5 @@ module.exports = {
Eq,
IsNotNull,
Like,
+ StartsWith
};
diff --git a/src/backend/src/routers/confirm-email.js b/src/backend/src/routers/confirm-email.js
index 465dcb0c21..360ff3a02d 100644
--- a/src/backend/src/routers/confirm-email.js
+++ b/src/backend/src/routers/confirm-email.js
@@ -43,15 +43,22 @@ router.post('/confirm-email', auth, express.json(), async (req, res, next)=>{
const db = req.services.get('database').get(DB_WRITE, 'auth');
// Increment & check rate limit
- if(kv.incr(`confirm-email|${req.ip}|${req.body.email ?? req.body.username}`) > 10)
+ if(kv.incr(`confirm-email|${req.ip}|${req.user.email ?? req.user.username}`) > 10)
return res.status(429).send({error: 'Too many requests.'});
// Set expiry for rate limit
- kv.expire(`confirm-email|${req.ip}|${req.body.email ?? req.body.username}`, 60 * 10, 'NX')
+ kv.expire(`confirm-email|${req.ip}|${req.user.email ?? req.user.username}`, 60 * 10, 'NX')
+
+ console.log('need to check these', typeof req.body.code, typeof req.user.email_confirm_code, req.body.code, req.user.email_confirm_code);
+
+ if(req.body.code !== req.user.email_confirm_code) {
+ res.send({ email_confirmed: false });
+ return;
+ }
// Scenario: email was confirmed on another account already
{
const svc_cleanEmail = req.services.get('clean-email');
- const clean_email = svc_cleanEmail.clean(req.body.email);
+ const clean_email = svc_cleanEmail.clean(req.user.email);
if ( ! await svc_cleanEmail.validate(clean_email) ) {
APIError.create('field_invalid', null, {
@@ -63,7 +70,7 @@ router.post('/confirm-email', auth, express.json(), async (req, res, next)=>{
const rows = await db.read(
`SELECT EXISTS(
SELECT 1 FROM user WHERE (email=? OR clean_email=?) AND email_confirmed=1 AND password IS NOT NULL
- ) AS email_exists`, [req.body.email, clean_email]);
+ ) AS email_exists`, [req.user.email, clean_email]);
if ( rows[0].email_exists ) {
APIError.create('email_already_in_use').write(res);
return;
@@ -76,37 +83,34 @@ router.post('/confirm-email', auth, express.json(), async (req, res, next)=>{
[req.user.email],
);
- if(req.body.code === req.user.email_confirm_code) {
- await db.write(
- "UPDATE `user` SET `email_confirmed` = 1, `requires_email_confirmation` = 0 WHERE id = ? LIMIT 1",
- [req.user.id],
- );
- const svc_getUser = req.services.get('get-user');
- await svc_getUser.get_user({ id: req.user.id, force: true });
-
- const svc_event = req.services.get('event');
- svc_event.emit('user.email-confirmed', {
- user_uid: req.user.uuid,
- email: req.user.email,
- });
- }
+ // Update user record to say email is confirmed
+ await db.write(
+ "UPDATE `user` SET `email_confirmed` = 1, `requires_email_confirmation` = 0 WHERE id = ? LIMIT 1",
+ [req.user.id],
+ );
+
+ // Invalidate user cache
+ const svc_getUser = req.services.get('get-user');
+ await svc_getUser.get_user({ id: req.user.id, force: true });
- // Build response object
- const res_obj = {
- email_confirmed: (req.body.code === req.user.email_confirm_code),
- original_client_socket_id: req.body.original_client_socket_id,
- }
+ // Emit internal event
+ const svc_event = req.services.get('event');
+ svc_event.emit('user.email-confirmed', {
+ user_uid: req.user.uuid,
+ email: req.user.email,
+ });
- // Send realtime success msg to client
- if(req.body.code === req.user.email_confirm_code){
- const svc_socketio = req.services.get('socketio');
- svc_socketio.send({ room: req.user.id }, 'user.email_confirmed', {
- original_client_socket_id: req.body.original_client_socket_id
- });
- }
+ // Emit websocket event (TODO: should come from internal event above)
+ const svc_socketio = req.services.get('socketio');
+ svc_socketio.send({ room: req.user.id }, 'user.email_confirmed', {
+ original_client_socket_id: req.body.original_client_socket_id
+ });
// return results
- return res.send(res_obj)
+ return res.send({
+ email_confirmed: true,
+ original_client_socket_id: req.body.original_client_socket_id,
+ });
})
module.exports = router
diff --git a/src/backend/src/routers/filesystem_api/stat.js b/src/backend/src/routers/filesystem_api/stat.js
index 6680714b78..8c0501b054 100644
--- a/src/backend/src/routers/filesystem_api/stat.js
+++ b/src/backend/src/routers/filesystem_api/stat.js
@@ -43,6 +43,7 @@ module.exports = eggspress('/stat', {
user: req.user,
return_subdomains: req.body.return_subdomains,
return_permissions: req.body.return_permissions,
+ return_shares: req.body.return_shares,
return_versions: req.body.return_versions,
return_size: req.body.return_size,
});
diff --git a/src/backend/src/routers/save_account.js b/src/backend/src/routers/save_account.js
index b54af39915..f7f438a509 100644
--- a/src/backend/src/routers/save_account.js
+++ b/src/backend/src/routers/save_account.js
@@ -147,7 +147,7 @@ router.post('/save_account', auth, express.json(), async (req, res, next)=>{
// password
await bcrypt.hash(req.body.password, 8),
// email_confirm_code
- email_confirm_code,
+ '' + email_confirm_code,
//email_confirm_token
email_confirm_token,
// referred_by
diff --git a/src/backend/src/routers/send-confirm-email.js b/src/backend/src/routers/send-confirm-email.js
index 5b5e26a0f8..76ccbfaf2e 100644
--- a/src/backend/src/routers/send-confirm-email.js
+++ b/src/backend/src/routers/send-confirm-email.js
@@ -46,7 +46,7 @@ router.post('/send-confirm-email', auth, express.json(), async (req, res, next)=
`UPDATE user SET email_confirm_code = ? WHERE id = ?`,
[
// email_confirm_code
- email_confirm_code,
+ '' + email_confirm_code,
// id
req.user.id,
]);
diff --git a/src/backend/src/routers/signup.js b/src/backend/src/routers/signup.js
index ce972cd801..63fdbc2cab 100644
--- a/src/backend/src/routers/signup.js
+++ b/src/backend/src/routers/signup.js
@@ -271,7 +271,7 @@ module.exports = eggspress(['/signup'], {
// referrer
req.body.referrer ?? null,
// email_confirm_code
- email_confirm_code,
+ '' + email_confirm_code,
// email_confirm_token
email_confirm_token,
// free_storage
diff --git a/src/backend/src/services/EntityStoreService.js b/src/backend/src/services/EntityStoreService.js
index 334d09ca8d..2bae2f92b1 100644
--- a/src/backend/src/services/EntityStoreService.js
+++ b/src/backend/src/services/EntityStoreService.js
@@ -194,7 +194,7 @@ class EntityStoreService extends BaseService {
om: this.om,
});
- const predicate = await idu.detect_identifier(id ?? {});
+ const predicate = await idu.detect_identifier(id ?? {}, true);
if ( predicate ) {
const maybe_entity = await this.select({ predicate, limit: 1 });
if ( maybe_entity.length ) {
diff --git a/src/backend/src/services/LocalDiskStorageService.js b/src/backend/src/services/LocalDiskStorageService.js
index 79434bde9b..e093f130fb 100644
--- a/src/backend/src/services/LocalDiskStorageService.js
+++ b/src/backend/src/services/LocalDiskStorageService.js
@@ -18,6 +18,7 @@
* along with this program. If not, see .
*/
const { LocalDiskStorageStrategy } = require("../filesystem/strategies/storage_a/LocalDiskStorageStrategy");
+const { PuterFSProvider } = require("../modules/puterfs/lib/PuterFSProvider");
const { TeePromise } = require('@heyputer/putility').libs.promise;
const { progress_stream, size_limit_stream } = require("../util/streamutil");
const BaseService = require("./BaseService");
@@ -52,7 +53,7 @@ class LocalDiskStorageService extends BaseService {
svc_contextInit.register_value('storage', storage);
const svc_mountpoint = this.services.get('mountpoint');
- svc_mountpoint.set_storage(storage);
+ svc_mountpoint.set_storage(PuterFSProvider, storage);
}
diff --git a/src/backend/src/services/MemoryStorageService.js b/src/backend/src/services/MemoryStorageService.js
new file mode 100644
index 0000000000..98b16acee8
--- /dev/null
+++ b/src/backend/src/services/MemoryStorageService.js
@@ -0,0 +1,42 @@
+// METADATA // {"ai-commented":{"service":"mistral","model":"mistral-large-latest"}}
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+const BaseService = require("./BaseService");
+const { MemoryFSProvider } = require("../modules/puterfs/customfs/MemoryFSProvider");
+const { Readable } = require("stream");
+
+class MemoryStorageService extends BaseService {
+ async _init () {
+ console.log('MemoryStorageService._init');
+
+ const svc_mountpoint = this.services.get('mountpoint');
+ svc_mountpoint.set_storage(MemoryFSProvider, this);
+ }
+
+ async create_read_stream (uuid, options) {
+ const memory_file = options?.memory_file;
+ if ( ! memory_file ) {
+ throw new Error('MemoryStorageService.create_read_stream: memory_file is required');
+ }
+
+ return Readable.from(memory_file.content);
+ }
+}
+
+module.exports = MemoryStorageService;
\ No newline at end of file
diff --git a/src/backend/src/services/PuterHomepageService.js b/src/backend/src/services/PuterHomepageService.js
index 69fc126f63..e2a3607da4 100644
--- a/src/backend/src/services/PuterHomepageService.js
+++ b/src/backend/src/services/PuterHomepageService.js
@@ -216,18 +216,19 @@ class PuterHomepageService extends BaseService {
const bundled = env != 'dev' || use_bundled_gui;
- // if social media image is not a valid absolute URL, set it to null
- if (social_media_image && !is_valid_url(social_media_image)) {
- social_media_image = null;
+ // check if social media image is a valid absolute URL
+ let is_social_media_image_valid = !!social_media_image;
+ if (is_social_media_image_valid && !is_valid_url(social_media_image)) {
+ is_social_media_image_valid = false;
}
- // social media image must end with a valid image extension
- if (social_media_image && !/\.(png|jpg|jpeg|gif|webp)$/.test(social_media_image.toLowerCase())) {
- social_media_image = null;
+ // check if social media image ends with a valid image extension
+ if (is_social_media_image_valid && !/\.(png|jpg|jpeg|gif|webp)$/.test(social_media_image.toLowerCase())) {
+ is_social_media_image_valid = false;
}
// set social media image to default if it is not valid
- const social_media_image_url = social_media_image || `${asset_dir}/images/screenshot.png`;
+ const social_media_image_url = is_social_media_image_valid ? social_media_image : `${asset_dir}/images/screenshot.png`;
// Custom script tags to be added to the homepage by extensions
// an event is emitted to allow extensions to add their own script tags
diff --git a/src/backend/src/services/auth/AuthService.js b/src/backend/src/services/auth/AuthService.js
index 24c04c0b0f..ec275c6c15 100644
--- a/src/backend/src/services/auth/AuthService.js
+++ b/src/backend/src/services/auth/AuthService.js
@@ -220,7 +220,7 @@ class AuthService extends BaseService {
version: '0.0.0',
user_uid: actor_type.user.uuid,
app_uid,
- session: this.uuid_fpe.encrypt(actor_type.session),
+ ...(actor_type.session ? { session: this.uuid_fpe.encrypt(actor_type.session) } : {}),
},
this.global_config.jwt_secret,
);
diff --git a/src/backend/src/services/worker/WorkerService.js b/src/backend/src/services/worker/WorkerService.js
index 9eea64583e..d0d1b0de2a 100644
--- a/src/backend/src/services/worker/WorkerService.js
+++ b/src/backend/src/services/worker/WorkerService.js
@@ -24,7 +24,53 @@ const fs = require("node:fs");
const { createWorker, setCloudflareKeys, deleteWorker } = require("./workerUtils/cloudflareDeploy");
const { getUserInfo } = require("./workerUtils/puterUtils");
+const { LLRead } = require("../../filesystem/ll_operations/ll_read");
+const { Context } = require("../../util/context");
+const { NodePathSelector, NodeUIDSelector } = require("../../filesystem/node/selectors");
+const { calculateWorkerNameNew } = require("./workerUtils/nameUtils");
+const { Entity } = require("../../om/entitystorage/Entity");
+const { SKIP_ES_VALIDATION } = require("../../om/entitystorage/consts");
+const { Eq, StartsWith } = require("../../om/query/query");
+const { get_app, subdomain } = require("../../helpers");
+const { UsernameNotifSelector } = require("../NotificationService");
+const APIError = require("../../api/APIError");
+const FSNodeParam = require("../../api/filesystem/FSNodeParam");
+const { UserActorType } = require("../auth/Actor");
+async function readPuterFile(actor, filePath) {
+ try {
+ const svc_fs = this.services.get('filesystem');
+ const node = await svc_fs.node(new NodePathSelector(filePath));
+ const ll_read = new LLRead();
+ const stream = await ll_read.run({
+ fsNode: node,
+ actor,
+ });
+ const chunks = [];
+ let bytes = 0;
+ stream.on("data", (data) => {
+ chunks.push(data)
+ bytes += data.byteLength
+ if (bytes > 10 ** 7) {
+ const err = Error("Worker source code must not exceed 10MB");
+ stream.emit("error", err);
+ throw err;
+ }
+ });
+ return new Promise((res, rej) => {
+ stream.on("error", (e) => {
+ rej(e.toString());
+ });
+ stream.on("end", () => {
+ res(Buffer.concat(chunks));
+ })
+ })
+ } catch (e) {
+ console.error(e)
+ }
+
+
+}
// This file is generated by webpack. To rebuild: cd to this directory and run `npm run build`
let preamble;
try {
@@ -32,32 +78,246 @@ try {
} catch (e) {
preamble = "";
console.error("WORKERS ERROR: Preamble has not been built! Workers will not have access to puter.js\nTo fix this cd into src/backend/src/worker and run npm run build")
-}
+}
const PREAMBLE_LENGTH = preamble.split("\n").length - 1
-
class WorkerService extends BaseService {
- ['__on_install.routes'](_, { app }) {
+ _init() {
setCloudflareKeys(this.config);
+ // Services used
+ const svc_event = this.services.get('event');
+ const svc_su = this.services.get("su");
+ const es_subdomain = this.services.get('es:subdomain');
+ const svc_auth = this.services.get("auth");
+ const svc_notification = this.services.get('notification');
+
+ svc_event.on('fs.written.file', async (_key, data, meta) => {
+ // Code should only run on the same server as the write
+ if (meta.from_outside) return;
+
+ // Check if the file that was written correlates to a worker
+ const results = await svc_su.sudo(async () => {
+ return await es_subdomain.select({ predicate: new Eq({ key: "root_dir", value: data.node }) });
+ });
+ if (!results || results.length === 0)
+ return;
+
+ for (const result of results) {
+ // Person who just wrote file (not necessarily file owner)
+ const actor = Context.get("actor");
+
+ // Worker data
+ const fileData = (await readPuterFile(Context.get("actor"), data.node.path)).toString();
+ const workerName = (await result.get("subdomain")).split(".").pop();
+
+ // Get appropriate deploy time auth token to give to the worker
+ let authToken;
+ const appOwner = await result.get("app_owner");
+ if (appOwner) { // If the deployer is an app...
+ const appID = await appOwner.get("uid");
+ authToken = await svc_su.sudo(await data.node.get("owner"), async () => {
+ return await svc_auth.get_user_app_token(appID);
+ })
+ } else { // If the deployer is not attached to any application
+ authToken = (await svc_auth.create_session_token((await data.node.get("owner")).type.user)).token
+ }
+
+
+ // svc_notification.notify(
+ // UsernameNotifSelector(actor.type.user.username),
+ // {
+ // source: 'worker',
+ // title: `Deploying CF worker ${workerName}`,
+ // template: 'user-requesting-share',
+ // fields: {
+ // username: actor.type.user.username,
+ // },
+ // }
+ // );
+ try {
+ // Create the worker
+ const cfData = await createWorker((await data.node.get("owner")).type.user, authToken, workerName, preamble + fileData, PREAMBLE_LENGTH);
+
+ // Send user the appropriate notification
+ if (cfData.success) {
+ // svc_notification.notify(
+ // UsernameNotifSelector(actor.type.user.username),
+ // {
+ // source: 'worker',
+ // title: `Succesfully deployed ${cfData.url}`,
+ // template: 'user-requesting-share',
+ // fields: {
+ // username: actor.type.user.username,
+ // },
+ // }
+ // );
+ } else {
+ svc_notification.notify(
+ UsernameNotifSelector(actor.type.user.username),
+ {
+ source: 'worker',
+ title: `Failed to deploy ${workerName}! ${cfData.errors}`,
+ template: 'user-requesting-share',
+ fields: {
+ username: actor.type.user.username,
+ },
+ }
+ );
+ }
+
+
+ } catch (e) {
+ svc_notification.notify(
+ UsernameNotifSelector(actor.type.user.username),
+ {
+ source: 'worker',
+ title: `Failed to deploy ${workerName}!!\n ${e}`,
+ template: 'user-requesting-share',
+ fields: {
+ username: actor.type.user.username,
+ },
+ }
+ );
+ }
+ }
+ });
}
static IMPLEMENTS = {
['workers']: {
- async create({ fileData, workerName, authorization }) {
+ /**
+ *
+ * @param {{filePath: string, workerName: string, authorization: string}} param0
+ * @returns {any}
+ */
+ async create({ filePath, workerName, authorization, appId }) {
try {
+ workerName = workerName.toLocaleLowerCase(); // just incase
+ const svc_su = this.services.get("su");
+ const es_subdomain = this.services.get('es:subdomain');
+ const svc_auth = this.services.get("auth");
+
+ const currentDomains = await svc_su.sudo(Context.get("actor").get_related_actor(UserActorType), async () => {
+ return (await es_subdomain.select({ predicate: new StartsWith({ key: "subdomain", value: "workers.puter." }) }));
+ });
+
+ if (appId) {
+ const app = await get_app({uid: appId});
+ if (Context.get("actor").type.user.id !== app.owner_user_id)
+ throw APIError.create('no_suitable_app', null, { entry_name: workerName });
+
+ authorization = await svc_auth.get_user_app_token(appId);
+ }
+
+ if (currentDomains.length >= 100) {
+ throw APIError.create('subdomain_limit_reached', null, {isWorker: true, limit: 100});
+ }
+
+ if (this.global_config.reserved_words.includes(workerName)) {
+ throw APIError.create('subdomain_reserved', null, {
+ subdomain: workerName,
+ });
+ }
+
+ if (!(/^[a-zA-Z0-9_-]+$/.test(workerName))) return;
+
+ filePath = await (await (new FSNodeParam('path')).consolidate({
+ req: { user: Context.get("actor").type.user },
+ getParam: () => filePath,
+ })).get("path");
+
const userData = await getUserInfo(authorization, this.global_config.api_base_url);
- return await createWorker(userData, authorization, workerName, preamble + fileData, PREAMBLE_LENGTH);
+ const actor = Context.get("actor");
+ if (appId) {
+ await svc_su.sudo(await svc_auth.authenticate_from_token(authorization), async()=> {
+ await Context.sub({ [SKIP_ES_VALIDATION]: true }).arun(async () => {
+ const entity = await Entity.create({ om: es_subdomain.om }, {
+ subdomain: "workers.puter." + calculateWorkerNameNew(userData, workerName),
+ root_dir: filePath
+ });
+ await es_subdomain.upsert(entity);
+ });
+ });
+ } else {
+ await Context.sub({ [SKIP_ES_VALIDATION]: true }).arun(async () => {
+ const entity = await Entity.create({ om: es_subdomain.om }, {
+ subdomain: "workers.puter." + calculateWorkerNameNew(userData, workerName),
+ root_dir: filePath
+ });
+ await es_subdomain.upsert(entity);
+ });
+ }
+
+
+ const fileData = (await readPuterFile(actor, filePath)).toString();
+ const cfData = await createWorker(userData, authorization, calculateWorkerNameNew(userData.uuid, workerName), preamble + fileData, PREAMBLE_LENGTH);
+
+
+ return cfData;
} catch (e) {
- return {success: false, e}
+ if (e instanceof APIError)
+ throw e;
+ console.error(e)
+ return { success: false, errors: e }
}
},
async destroy({ workerName, authorization }) {
try {
+ workerName = workerName.toLocaleLowerCase(); // just incase
+ const svc_su = this.services.get("su");
+ const es_subdomain = this.services.get('es:subdomain');
+
const userData = await getUserInfo(authorization, this.global_config.api_base_url);
- return await deleteWorker(userData, authorization, workerName);
+
+ const [result] = (await es_subdomain.select({ predicate: new Eq({ key: "subdomain", value: "workers.puter." + calculateWorkerNameNew(undefined, workerName) }) }));
+
+ if (result.values_.owner.uuid !== userData.uuid) {
+ throw new Error("This is not your worker!");
+ }
+
+ const cfData = await deleteWorker(userData, authorization, workerName);
+
+
+ await es_subdomain.delete(await result.get("uid"));
+ return cfData;
+
+
} catch (e) {
- return {success: false, e}
+ if (e instanceof APIError)
+ throw e;
+ console.error(e);
+ return { success: false, e }
}
},
+ async getFilePaths({workerName}) {
+ try {
+ const es_subdomain = this.services.get('es:subdomain');
+ let currentDomains;
+ if (typeof(workerName) !== "string") {
+ currentDomains = (await es_subdomain.select({ predicate: new StartsWith({ key: "subdomain", value: "workers.puter." }) }));
+ } else {
+ currentDomains = (await es_subdomain.select({ predicate: new Eq({ key: "subdomain", value: "workers.puter." + workerName}) }));
+ }
+ const svc_fs = this.services.get('filesystem');
+
+ const domainToPath = []
+ for (const domain of currentDomains) {
+ const node = await domain.get("root_dir")
+ const subdomainString = (await domain.get("subdomain"))
+ let file_path = null;
+ let file_uid = null;
+ try {
+ file_path = await node.get("path");
+ file_uid = await node.get("uid");
+ } catch (e) {
+ }
+ domainToPath.push({ name: subdomainString.split(".").pop(), url: `https://${subdomainString}`, file_path, file_uid, created_at: (new Date(await domain.get("created_at"))).toISOString() });
+ }
+ return domainToPath;
+ } catch (e) {
+ console.error(e)
+ }
+
+ },
async startLogs({ workerName, authorization }) {
return await this.exec_({ runtime, code });
},
@@ -73,12 +333,22 @@ class WorkerService extends BaseService {
col_interfaces.set('workers', {
description: 'Execute code with various languages.',
methods: {
+ getFilePaths: {
+ description: 'get paths for your workers',
+ parameters: {
+ workerName: {
+ type: "string",
+ description: "Optionally, the name of the worker you want the path for"
+ }
+ },
+ result: {type: 'json'}
+ },
create: {
description: 'Create a backend worker',
parameters: {
- fileData: {
+ filePath: {
type: "string",
- description: "The code of the worker to upload"
+ description: "The path of the code of the worker to upload"
},
workerName: {
type: "string",
@@ -87,6 +357,10 @@ class WorkerService extends BaseService {
authorization: {
type: "string",
description: "Puter token"
+ },
+ appId: {
+ type: "string",
+ description: "App ID to tie a worker to"
}
},
result: { type: 'json' },
diff --git a/src/backend/src/services/worker/src/s2w-router.js b/src/backend/src/services/worker/src/s2w-router.js
index b73e76912e..8299203306 100644
--- a/src/backend/src/services/worker/src/s2w-router.js
+++ b/src/backend/src/services/worker/src/s2w-router.js
@@ -2,8 +2,9 @@ import { match } from 'path-to-regexp';
function inits2w() {
// s2w router itself: Not part of any package, just a simple router.
- const s2w = {
+ const router = {
routing: true,
+ handleCors: true,
map: new Map(),
custom(eventName, route, eventListener) {
const matchExp = match(route);
@@ -28,14 +29,58 @@ function inits2w() {
delete(...args) {
this.custom("DELETE", ...args)
},
+ async handleOptions(request) {
+ const corsHeaders = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET,HEAD,POST,OPTIONS",
+ "Access-Control-Max-Age": "86400",
+ };
+ if (
+ request.headers.get("Origin") !== null &&
+ request.headers.get("Access-Control-Request-Method") !== null &&
+ request.headers.get("Access-Control-Request-Headers") !== null
+ ) {
+ // Handle CORS preflight requests.
+ return new Response(null, {
+ headers: {
+ ...corsHeaders,
+ "Access-Control-Allow-Headers": request.headers.get(
+ "Access-Control-Request-Headers",
+ ),
+ },
+ });
+ } else {
+ // Handle standard OPTIONS request.
+ return new Response(null, {
+ headers: {
+ Allow: "GET, HEAD, POST, OPTIONS",
+ },
+ });
+ }
+ },
+ /**
+ *
+ * @param {FetchEvent } event
+ * @returns
+ */
async route(event) {
- if (!globalThis.puter) {
- console.log("Puter not loaded, initializing...");
- const success = init_puter_portable(globalThis.puter_auth, globalThis.puter_endpoint || "https://api.puter.com");
- console.log("Puter.js initialized successfully");
+ if (!globalThis.me) {
+ globalThis.me = { puter: init_puter_portable(globalThis.puter_auth, globalThis.puter_endpoint || "https://api.puter.com", "userPuter") }
+ globalThis.my = me;
+ globalThis.myself = me;
+ }
+ if (event.request.headers.has("puter-auth")) {
+ event.requestor = { puter: init_puter_portable(event.request.headers.get("puter-auth"), globalThis.puter_endpoint || "https://api.puter.com", "userPuter") };
+ event.user = event.requestor;
}
-
+
const mappings = this.map.get(event.request.method);
+ if (this.handleCors && event.request.method === "OPTIONS" && !mappings) {
+ return this.handleOptions(event.request);
+ }
+ if (!mappings) {
+ return new Response(`No routes for given request type ${event.request.method}`, {status: 404});
+ }
const url = new URL(event.request.url);
try {
for (const mapping of mappings) {
@@ -43,21 +88,45 @@ function inits2w() {
const results = mapping[0](url.pathname)
if (results) {
event.params = results.params;
- return mapping[1](event);
+ let response = await mapping[1](event);
+ if (!(response instanceof Response)) {
+ try {
+ if (response instanceof Blob ||
+ response instanceof ArrayBuffer ||
+ response instanceof Uint8Array.__proto__ ||
+ response instanceof ReadableStream ||
+ response instanceof URLSearchParams ||
+ typeof (response) === "string") {
+ response = new Response(response);
+ } else {
+ response = new Response(JSON.stringify(response), { headers: { 'content-type': 'application/json' } })
+ }
+ } catch (e) {
+ throw new Error("Returned response by handler was neither a Response object nor an object which can implicitly be converted into a Response object");
+ }
+ }
+ if (this.handleCors && !response.headers.has("access-control-allow-origin")) {
+ response.headers.set("Access-Control-Allow-Origin", "*");
+ }
+ return response;
}
}
} catch (e) {
- return new Response(e, {status: 500, statusText: "Server Error"})
+ const response = new Response(e, { status: 500, statusText: "Server Error" })
+ if (this.handleCors && !response.headers.has("access-control-allow-origin")) {
+ response.headers.set("Access-Control-Allow-Origin", "*");
+ }
+ return response;
}
- return new Response("Path not found", {status: 404, statusText: "Not found"});
+ return new Response("Path not found", { status: 404, statusText: "Not found" });
}
}
- globalThis.s2w = s2w;
- self.addEventListener("fetch", (event)=> {
- if (!s2w.routing)
+ globalThis.router = router;
+ self.addEventListener("fetch", (event) => {
+ if (!router.routing)
return false;
- event.respondWith(s2w.route(event));
+ event.respondWith(router.route(event));
})
}
diff --git a/src/backend/src/services/worker/template/puter-portable.js b/src/backend/src/services/worker/template/puter-portable.js
index 9e8222240c..53a958084d 100644
--- a/src/backend/src/services/worker/template/puter-portable.js
+++ b/src/backend/src/services/worker/template/puter-portable.js
@@ -15,9 +15,7 @@ if (globalThis.Cloudflare) {
}
}
-globalThis.init_puter_portable = (auth, apiOrigin) => {
- console.log("Starting puter.js initialization");
-
+globalThis.init_puter_portable = (auth, apiOrigin, type) => {
// Who put C in my JS??
/*
* This is a hack to include the puter.js file.
@@ -25,9 +23,27 @@ globalThis.init_puter_portable = (auth, apiOrigin) => {
* The puter.js file is handled by the C preprocessor here because webpack cant behave with already minified files.
* The C preprocessor basically just includes the file and then we can use the puter.js file in the worker.
*/
- #include "../../../../../puter-js/dist/puter.js"
- puter.setAPIOrigin(apiOrigin);
- puter.setAuthToken(auth);
+ if (type === "userPuter") {
+ const goodContext = {}
+ Object.getOwnPropertyNames(globalThis).forEach(name => { try { goodContext[name] = globalThis[name]; } catch {} })
+ goodContext.globalThis = goodContext;
+ goodContext.WorkerGlobalScope = WorkerGlobalScope;
+ goodContext.ServiceWorkerGlobalScope = ServiceWorkerGlobalScope;
+ goodContext.location = new URL("https://puter.work");
+ goodContext.addEventListener = ()=>{};
+ // @ts-ignore
+ with (goodContext) {
+ #include "../../../../../puter-js/dist/puter.js"
+ }
+ goodContext.puter.setAPIOrigin(apiOrigin);
+ goodContext.puter.setAuthToken(auth);
+ return goodContext.puter;
+ } else {
+ #include "../../../../../puter-js/dist/puter.js"
+
+ puter.setAPIOrigin(apiOrigin);
+ puter.setAuthToken(auth);
+ }
}
#include "../dist/webpackPreamplePart.js"
diff --git a/src/backend/src/services/worker/workerUtils/cloudflareDeploy.js b/src/backend/src/services/worker/workerUtils/cloudflareDeploy.js
index 956801460d..eff0bdced6 100644
--- a/src/backend/src/services/worker/workerUtils/cloudflareDeploy.js
+++ b/src/backend/src/services/worker/workerUtils/cloudflareDeploy.js
@@ -1,5 +1,5 @@
const fs = require('fs')
-const { calculateWorkerName } = require("./nameUtils.js");
+const { calculateWorkerNameNew } = require("./nameUtils.js");
let config = {};
// Constants
const CF_BASE_URL = "https://api.cloudflare.com/"
@@ -16,15 +16,15 @@ function cfFetch(url, method = "GET", body, givenHeaders) {
return fetch(url, { headers, method, body })
}
async function getWorker(userData, authorization, workerId) {
- await cfFetch(`${WORKERS_BASE_URL}/scripts/${calculateWorkerName(userData.username, workerId)}`, "GET");
+ await cfFetch(`${WORKERS_BASE_URL}/scripts/${calculateWorkerNameNew(userData.uuid, workerId)}`, "GET");
}
-async function createWorker(userData, authorization, workerId, body, PREAMBLE_LENGTH) {
- console.log(body)
+async function createWorker(userData, authorization, workerName, body, PREAMBLE_LENGTH) {
const formData = new FormData();
const workerMetaData = {
body_part: "swCode",
+ compatibility_date: "2025-07-15",
bindings: [
{
type: "secret_text",
@@ -42,10 +42,10 @@ async function createWorker(userData, authorization, workerId, body, PREAMBLE_LE
}
formData.append("metadata", JSON.stringify(workerMetaData));
formData.append("swCode", body);
- const cfReturnCodes = await (await cfFetch(`${WORKERS_BASE_URL}/scripts/${calculateWorkerName(userData.username, workerId)}/`, "PUT", formData)).json();
+ const cfReturnCodes = await (await cfFetch(`${WORKERS_BASE_URL}/scripts/${workerName}/`, "PUT", formData)).json();
if (cfReturnCodes.success) {
- return JSON.stringify({ success: true, errors: [], url: `${calculateWorkerName(userData.username, workerId)}.puter.work` });
+ return { success: true, errors: [], url: `https://${workerName}.puter.work` };
} else {
const parsedErrors = [];
for (const error of cfReturnCodes.errors) {
@@ -71,7 +71,7 @@ async function createWorker(userData, authorization, workerId, body, PREAMBLE_LE
parsedErrors.push(finalMessage)
}
- return JSON.stringify({ success: false, errors: parsedErrors, url: null, body });
+ return { success: false, errors: parsedErrors, url: null, body };
}
}
function setPreambleLength(length) {
@@ -87,7 +87,7 @@ function setCloudflareKeys(givenConfig) {
}
async function deleteWorker(userData, authorization, workerId) {
- return await (await cfFetch(`${WORKERS_BASE_URL}/scripts/${calculateWorkerName(userData.username, workerId)}/`, "DELETE")).json();
+ return await (await cfFetch(`${WORKERS_BASE_URL}/scripts/${calculateWorkerNameNew(userData.uuid, workerId)}/`, "DELETE")).json();
}
diff --git a/src/backend/src/services/worker/workerUtils/nameUtils.js b/src/backend/src/services/worker/workerUtils/nameUtils.js
index c0db12d23a..06d756ee9a 100644
--- a/src/backend/src/services/worker/workerUtils/nameUtils.js
+++ b/src/backend/src/services/worker/workerUtils/nameUtils.js
@@ -4,11 +4,12 @@ const crypto = require("node:crypto");
function sha1(input) {
return crypto.createHash('sha1').update(input, 'utf8').digest().toString("hex").slice(0, 7)
}
-function calculateWorkerName(username, workerId) {
- return `${username}-${sha1(workerId).slice(0, 7)}`
-}
+function calculateWorkerNameNew(uuid, workerId) {
+
+ return `${workerId}`; // Used to be ${workerId}-${uuid.replaceAll("-", "")}
+}
module.exports = {
sha1,
- calculateWorkerName
+ calculateWorkerNameNew
}
\ No newline at end of file
diff --git a/src/dev-center/css/style.css b/src/dev-center/css/style.css
index e823f9f99a..2edddd9ae3 100644
--- a/src/dev-center/css/style.css
+++ b/src/dev-center/css/style.css
@@ -24,9 +24,7 @@
}
html {
- width: 100vw;
height: 100vh;
- background-color: #eff1f5
}
body{
@@ -37,7 +35,7 @@ body{
flex: 1;
}
-h1 .app-count{
+h1 .app-count, h1 .worker-count, h1 .website-count{
font-size: 20px;
color: #6d767d;
font-weight: 400;
@@ -46,6 +44,7 @@ h1 .app-count{
padding: 2px 10px;
border-radius: 3px;
}
+
/* ------------------------------------
Button
------------------------------------*/
@@ -72,10 +71,6 @@ h1 .app-count{
border-style: solid;
border-width: 1px;
line-height: 35px;
- background: -webkit-gradient(linear, left top, left bottom, from(#f6f6f6), to(#e1e1e1));
- background: linear-gradient(#f6f6f6, #e1e1e1);
- -webkit-box-shadow: inset 0px 1px 0px rgb(255 255 255 / 30%), 0 1px 2px rgb(0 0 0 / 15%);
- box-shadow: inset 0px 1px 0px rgb(255 255 255 / 30%), 0 1px 2px rgb(0 0 0 / 15%);
border-radius: 4px;
outline: none;
@@ -206,6 +201,25 @@ h1 .app-count{
padding: 0 24px;
}
+.refresh-app-list, .refresh-worker-list, .refresh-website-list {
+ width:40px;
+ padding: 10px;
+ float: right;
+ margin-right: 10px;
+ background-color: white;
+ border: none;
+ color: #0d6efd;
+ font-size: 13px;
+ cursor: pointer;
+ opacity: 0.9;
+}
+
+.refresh-app-list:hover, .refresh-worker-list:hover, .refresh-website-list:hover {
+ opacity: 1;
+}
+.refresh-icon{
+ width: 18px; height: 18px; margin-top: -2px; display: block;
+}
a {
color: #0d6efd;
text-decoration: none;
@@ -223,20 +237,23 @@ section {
padding: 10px;
overflow: hidden;
width: 100%;
- padding-right: 20px;
- padding-left: 20px;
+ padding-right: 40px;
+ padding-left: 40px;
box-sizing: border-box;
}
-#app-list{
+#app-list, #website-list, #worker-list {
display: none;
}
-#app-list-table > thead{
- font-size:14px; background-color: #f7fafc; border-top: 1px solid #DDD; text-transform: uppercase; color: #6d767d;
+#app-list-table > thead, #website-list-table > thead, #worker-list-table > thead{
+ font-size:14px;
+ border-top: 1px solid #DDD;
+ text-transform: uppercase;
+ color: #6d767d;
cursor: default;
}
-.app-card {
+.app-card, .website-card, .worker-card {
padding: 12px;
- border-top: 1px solid #d8dce5;
+ border-top: 1px solid #f1f2f5;
border-radius: 0;
clear: both;
background: white;
@@ -244,25 +261,33 @@ section {
position: relative;
}
-.app-card:hover {
- background-color: #e8eff66e;
+.app-card:hover, .website-card:hover, .worker-card:hover {
+ background-color: #f0f5fb6e;
}
-.app-card.active {
- background-color: #e8eff6;
+.app-card.active, .website-card.active, .worker-card.active {
+ background-color: #ebeff39a;
}
-.app-card:hover .app-row-toolbar{
- display: block;
+
+.app-card:hover .app-row-toolbar, .website-card:hover .website-row-toolbar, .worker-card:hover .worker-row-toolbar {
+ visibility: visible;
+ opacity: 1;
+}
+
+.app-toolbar-container, .website-toolbar-container, .worker-toolbar-container {
+ height: 16px;
+ overflow: hidden;
+ position: relative;
}
-.app-card h1 {
+.app-card h1, .website-card h1, .worker-card h1 {
margin-top: 0;
color: #657188;
font-weight: 400;
font-size: 25px;
}
-#create-app-success {
+#create-app-success, #create-website-success, #create-worker-success {
height: calc(100vh - 40px);
overflow: hidden;
text-align: center;
@@ -277,7 +302,7 @@ label, input[type="text"] {
user-select: none;
}
-#delete-app {
+#delete-app, #delete-website, #delete-worker {
cursor: pointer;
float: left;
margin-top: 30px;
@@ -285,7 +310,7 @@ label, input[type="text"] {
font-size: 13px;
}
-#delete-app:hover {
+#delete-app:hover, #delete-website:hover, #delete-worker:hover {
text-decoration: underline;
}
@@ -411,7 +436,7 @@ label {
}
.app-card-link:hover{
- text-decoration: underline;
+ text-decoration: underline !important;
}
.edit-app img {
@@ -430,17 +455,19 @@ label {
left: 0;
z-index: 1000;
display: block;
- padding: 30px 16px;
+ padding: 40px 16px 0;
overflow-x: hidden;
overflow-y: auto;
- background-color: white;
- border-right: 1px solid #e1e1e1;
+ background-color: rgb(249, 249, 249);
+ border-right: 1px solid #eeeeee;
color: rgb(51, 51, 51);
font-weight: 400;
width: 25px;
}
.sidebar .sidebar-content{
display: none;
+ position: relative;
+ height: 100%;
}
.sidebar.open{
width: 250px;
@@ -456,15 +483,16 @@ label {
right: 0;
top: 0;
padding: 10px;
- background-size: 15px;
+ background-size: 25px;
background-repeat: no-repeat;
background-position: center;
cursor: pointer;
- background-image: url("data:image/svg+xml,%3Csvg%20enable-background%3D%22new%200%200%2024%2024%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22m3%2023-3-3%208-8-8-8%203-3%2011%2011zm10%200-3-3%208-8-8-8%203-3%2011%2011z%22%2F%3E%3C%2Fsvg%3E");
- opacity: 0.3;
+ background-image: url("data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22utf-8%22%3F%3E%3C!--%20Uploaded%20to%3A%20SVG%20Repo%2C%20www.svgrepo.com%2C%20Generator%3A%20SVG%20Repo%20Mixer%20Tools%20--%3E%3Csvg%20fill%3D%22%23000000%22%20width%3D%22800px%22%20height%3D%22800px%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M15.2928932%2C12%20L12.1464466%2C8.85355339%20C11.9511845%2C8.65829124%2011.9511845%2C8.34170876%2012.1464466%2C8.14644661%20C12.3417088%2C7.95118446%2012.6582912%2C7.95118446%2012.8535534%2C8.14644661%20L16.8535534%2C12.1464466%20C17.0488155%2C12.3417088%2017.0488155%2C12.6582912%2016.8535534%2C12.8535534%20L12.8535534%2C16.8535534%20C12.6582912%2C17.0488155%2012.3417088%2C17.0488155%2012.1464466%2C16.8535534%20C11.9511845%2C16.6582912%2011.9511845%2C16.3417088%2012.1464466%2C16.1464466%20L15.2928932%2C13%20L4.5%2C13%20C4.22385763%2C13%204%2C12.7761424%204%2C12.5%20C4%2C12.2238576%204.22385763%2C12%204.5%2C12%20L15.2928932%2C12%20Z%20M19%2C5.5%20C19%2C5.22385763%2019.2238576%2C5%2019.5%2C5%20C19.7761424%2C5%2020%2C5.22385763%2020%2C5.5%20L20%2C19.5%20C20%2C19.7761424%2019.7761424%2C20%2019.5%2C20%20C19.2238576%2C20%2019%2C19.7761424%2019%2C19.5%20L19%2C5.5%20Z%22%2F%3E%3C%2Fsvg%3E");
+ opacity: 0.6;
+ margin-top: 5px;
}
.open .sidebar-toggle{
- background-image: url("data:image/svg+xml,%3Csvg%20enable-background%3D%22new%200%200%2024%2024%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22m21%201%203%203-8%208%208%208-3%203-11-11zm-10%200%203%203-8%208%208%208-3%203-11-11z%22%2F%3E%3C%2Fsvg%3E");
+ background-image: url("data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22utf-8%22%3F%3E%3C!--%20Uploaded%20to%3A%20SVG%20Repo%2C%20www.svgrepo.com%2C%20Generator%3A%20SVG%20Repo%20Mixer%20Tools%20--%3E%3Csvg%20fill%3D%22%23000000%22%20width%3D%22800px%22%20height%3D%22800px%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M8.70710678%2C12%20L19.5%2C12%20C19.7761424%2C12%2020%2C12.2238576%2020%2C12.5%20C20%2C12.7761424%2019.7761424%2C13%2019.5%2C13%20L8.70710678%2C13%20L11.8535534%2C16.1464466%20C12.0488155%2C16.3417088%2012.0488155%2C16.6582912%2011.8535534%2C16.8535534%20C11.6582912%2C17.0488155%2011.3417088%2C17.0488155%2011.1464466%2C16.8535534%20L7.14644661%2C12.8535534%20C6.95118446%2C12.6582912%206.95118446%2C12.3417088%207.14644661%2C12.1464466%20L11.1464466%2C8.14644661%20C11.3417088%2C7.95118446%2011.6582912%2C7.95118446%2011.8535534%2C8.14644661%20C12.0488155%2C8.34170876%2012.0488155%2C8.65829124%2011.8535534%2C8.85355339%20L8.70710678%2C12%20L8.70710678%2C12%20Z%20M4%2C5.5%20C4%2C5.22385763%204.22385763%2C5%204.5%2C5%20C4.77614237%2C5%205%2C5.22385763%205%2C5.5%20L5%2C19.5%20C5%2C19.7761424%204.77614237%2C20%204.5%2C20%20C4.22385763%2C20%204%2C19.7761424%204%2C19.5%20L4%2C5.5%20Z%22%2F%3E%3C%2Fsvg%3E");
}
.sidebar-toggle:hover{
opacity: 1;
@@ -483,22 +511,30 @@ label {
display: block;
padding: 10px 20px;
cursor: pointer;
+ margin-bottom: 10px;
+ margin-left: 10px;
+ margin-right: 10px;
+ border-radius: 7px;
+}
+
+.sidebar-nav>li:hover {
+ background-color: #656c771b;
}
.sidebar-nav>li.active {
color: #fff;
- background-color: #3273dc;
+ background-color: #3a85ff;
}
-.sidebar-nav > li .app-count{
- font-size: 14px;
+.sidebar-nav > li .app-count, .sidebar-nav > li .worker-count, .sidebar-nav > li .website-count {
+ font-size: 16px;
color: #6d767d;
font-weight: 400;
margin-left: 10px;
float: right;
}
-.tab-btn.active .app-count{
+.tab-btn.active .app-count, .tab-btn.active .worker-count, .tab-btn.active .website-count{
color: #f6f6f6;
}
@@ -528,21 +564,21 @@ label {
.main>section {
background-color: white;
- box-shadow: 0px 0px 3px #E1E1E1;
- border: 1px solid #e1e1e1;
border-radius: 3px;
}
.link-to-docs{
color: #586373;
- font-size: 15px;
-}
-.link-to-docs, .link-to-docs:hover {
+ font-size: 14px;
text-decoration: none;
}
+.link-to-docs:hover {
+ text-decoration: underline !important;
+}
.link-to-docs img{
width: 12px;
margin-bottom: -1px;
+ margin-left: 5px;
}
.tab-btn {
background-size: 20px;
@@ -560,6 +596,19 @@ label {
background-image: url(../img/apps-outline-black.svg);
}
+.tab-btn[data-tab="websites"] {
+ background-image: url(../img/website.svg);
+}
+.tab-btn.active[data-tab="websites"] {
+ background-image: url(../img/website-white.svg);
+}
+
+.tab-btn[data-tab="workers"] {
+ background-image: url(../img/workers.svg);
+}
+.tab-btn.active[data-tab="workers"] {
+ background-image: url(../img/workers-white.svg);
+}
.tab-btn[data-tab="payout-method"] {
background-image: url(../img/wallet.svg);
}
@@ -581,7 +630,7 @@ label {
box-sizing: border-box;
}
-#no-apps-notice, #loading {
+#no-apps-notice, #no-workers-notice, #no-websites-notice, #loading {
display: flex;
flex-direction: column;
justify-content: center;
@@ -618,7 +667,7 @@ table {
.table>thead>tr>th {
vertical-align: bottom;
- border-bottom: 2px solid #ddd;
+ border-bottom: 1px solid #ddd;
}
.table>thead>tr>th, .table>tbody>tr>th, .table>tfoot>tr>th, .table>thead>tr>td, .table>tbody>tr>td, .table>tfoot>tr>td {
@@ -628,10 +677,17 @@ table {
}
.table>thead>tr>th{
padding: 5px;
+ background-color: #f8f9fa;
+ font-weight: 600;
+ color: #676e77;
+ text-transform: uppercase;
+ font-size: 0.75rem;
+ letter-spacing: 0.05em;
}
th {
text-align: left;
padding-bottom: 0 !important;
+ font-size: 12px;
}
td, th {
@@ -708,23 +764,34 @@ th.sorted{
user-select: none !important;
}
-.my-apps-title {
- font-size: 27px;
+.my-apps-title, .my-workers-title, .my-websites-title {
+ font-size: 22px;
margin-top: 0px;
float: left;
font-weight: 500;
- margin-bottom: 15px;
+ margin-bottom: 0;
color: #394254;
+ flex-grow: 1;
}
-.app-row-toolbar{
- margin-top: -7px;
- display: none;
- width: 350px;
+.app-row-toolbar {
+ visibility: hidden;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ height: auto; /* allow full content */
+ overflow: visible; /* allow overflow */
+ display: flex;
+ align-items: center;
+ gap: 4px; /* adjust spacing here */
+ font-size: 12px;
+ padding-top: 2px;
+ margin-top: -4px;
+
}
.app-row-toolbar img {
opacity: 0.5;
+ transition: opacity 0.2s ease;
}
.app-row-toolbar img:hover {
@@ -897,14 +964,23 @@ ol li:before {
background-color: rgba(0, 0, 0, 0.4);
}
-.create-an-app-btn{
- float:right; margin-bottom: 10px;
+.create-an-app-btn, .create-a-website-btn{
+ float:right;
+ margin-bottom: 0px;
}
-.create-an-app-btn img{
- width: 20px;
+.create-an-app-btn img, .create-a-website-btn img, .create-a-worker-btn img{
+ width: 25px;
+ height: 25px;
margin-bottom: -5px;
margin-right: 10px;
+ margin-bottom: -7px;
+ margin-right: 4px;
}
+.create-a-worker-btn, .create-a-website-btn{
+ float:right;
+ margin-bottom: 0px;
+}
+
.jip-submit-btn{
float: left;
margin-top: 10px;
@@ -913,8 +989,13 @@ ol li:before {
.ip-terms-notice{
font-size: 12px; margin-top: 20px; margin-bottom: 0;
}
-.app-list-nav{
- overflow: hidden; margin-bottom: 40px; margin-top: 10px;
+.app-list-nav, .worker-list-nav, .website-list-nav{
+ overflow: hidden;
+ margin-bottom: 40px;
+ margin-top: 10px;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
}
.back-to-main-btn{
float:right; margin-bottom: 10px;
@@ -926,7 +1007,7 @@ ol li:before {
display: flex;
align-items: center;
}
-.app-title{
+.app-title, .website-title{
margin-top:0px;
margin-bottom: 0;
}
@@ -1068,20 +1149,36 @@ dialog{
width: 100%;
box-sizing: border-box;
background-color: white;
- padding: 5px;
- background-size: 20px;
+ padding: 8px;
+ background-size: 15px;
background-position-y: center;
- background-position-x: 5px;
+ background-position-x: 9px;
padding-left: 35px;
padding-right: 35px;
- border: 2px solid #CCC;
+ border: 2px solid #ebebeb;
+ font-size: 14px;
+}
+.search-container{
+ margin-bottom: 10px;
+ position: relative;
+ width: 300px;
+ float:left;
+}
+.search::placeholder {
+ opacity: 0.7;
+}
+.search:focus, .search:focus-within, .search:active, .search:focus-visible {
+ border: 2px solid #cbcbcb !important;
+ outline: none !important;
+}
+.search.has-value{
+ border: 2px solid #0066d2 !important;
}
-
.search-clear {
display: none;
position: absolute;
right: 6px;
- top: 6px;
+ top: 8px;
opacity: 0.3;
height: 20px;
}
@@ -1190,6 +1287,17 @@ dialog{
display: inline-block;
background-color: #e3f2fd;
color: #1976d2;
+ background-color: #f3f4f6;
+ color: #374151;
+ padding: 1px 6px;
+ border-radius: 4px;
+ font-size: 12px;
+ font-weight: 500;
+ margin: 1px 0 0 0;
+ max-width: fit-content;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.category-select {
@@ -1212,4 +1320,76 @@ dialog{
}
.stats-cell img{
width: 18px; margin-right: 5px; margin-bottom: -4px;
+}
+.category-badge {
+ display: inline-block;
+ background-color: #f2f2f2;
+ color: #555;
+ font-size: 12px;
+ font-weight: 500;
+ padding: 2px 6px;
+ margin-left: 8px;
+ border-radius: 6px;
+ vertical-align: middle;
+}
+.app-category {
+ display: inline-block;
+ background-color: #f3f4f6;
+ color: #374151;
+ padding: 1px 6px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 500;
+ margin-top: 2px;
+ max-height: 18px;
+ line-height: 1.2;
+ white-space: nowrap;
+ border: 1px solid #CCC;
+}
+
+.app-row-toolbar span {
+ margin: 0 4px;
+ font-size: 12px;
+ line-height: 1;
+ vertical-align: middle;
+ padding: 2px 4px;
+}
+
+.app-row-toolbar span:hover {
+ text-decoration: underline;
+ cursor: pointer;
+ color: #000; /* Optional: darken on hover */
+}
+
+.options-icon {
+ cursor: pointer;
+ opacity: 0.7;
+ width: 30px;
+ height: 30px;
+ border-radius: 5px;
+}
+
+.options-icon:hover {
+ opacity: 1;
+ background-color: #edededb4;
+}
+
+.worker-checkbox, .app-checkbox, .website-checkbox {
+ width: 15px; height: 20px;
+}
+
+.no-hover:hover {
+ background-color: transparent !important;
+}
+
+.root-dir-name{
+ cursor: pointer;
+}
+
+.worker-file-path{
+ cursor: pointer;
+}
+
+.tab-btn-separator{
+ display: none;
}
\ No newline at end of file
diff --git a/src/dev-center/icon.png b/src/dev-center/icon.png
new file mode 100644
index 0000000000..cd1cf794b5
Binary files /dev/null and b/src/dev-center/icon.png differ
diff --git a/src/dev-center/img/options.svg b/src/dev-center/img/options.svg
new file mode 100644
index 0000000000..6d0301c534
--- /dev/null
+++ b/src/dev-center/img/options.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/src/dev-center/img/plus.svg b/src/dev-center/img/plus.svg
new file mode 100644
index 0000000000..7ed5e22e6b
--- /dev/null
+++ b/src/dev-center/img/plus.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/dev-center/img/website-white.svg b/src/dev-center/img/website-white.svg
new file mode 100644
index 0000000000..7ef28b1af1
--- /dev/null
+++ b/src/dev-center/img/website-white.svg
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/src/dev-center/img/website.svg b/src/dev-center/img/website.svg
new file mode 100644
index 0000000000..5dcc7b279e
--- /dev/null
+++ b/src/dev-center/img/website.svg
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/src/dev-center/img/websites-placeholder.svg b/src/dev-center/img/websites-placeholder.svg
new file mode 100644
index 0000000000..06fb067b85
--- /dev/null
+++ b/src/dev-center/img/websites-placeholder.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/src/dev-center/img/workers-placeholder.svg b/src/dev-center/img/workers-placeholder.svg
new file mode 100644
index 0000000000..4da6d5d3a0
--- /dev/null
+++ b/src/dev-center/img/workers-placeholder.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/dev-center/img/workers-white.svg b/src/dev-center/img/workers-white.svg
new file mode 100644
index 0000000000..b664600efd
--- /dev/null
+++ b/src/dev-center/img/workers-white.svg
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/src/dev-center/img/workers.svg b/src/dev-center/img/workers.svg
new file mode 100644
index 0000000000..d2faa0fbfa
--- /dev/null
+++ b/src/dev-center/img/workers.svg
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/src/dev-center/index.html b/src/dev-center/index.html
index df732a9ba5..7a371e52c1 100644
--- a/src/dev-center/index.html
+++ b/src/dev-center/index.html
@@ -19,6 +19,9 @@
+
+
+
@@ -77,23 +80,28 @@
@@ -174,9 +182,26 @@ Payout Method
+
+
+
+
+
+
+ You haven't created any workers yet.
+ Create a Worker
+
+
+
+
+
+ You haven't created any websites yet.
+ Create a Website
+
@@ -209,26 +234,27 @@ Payout Method
My Apps
-
New App
-
+
New App
-
-
-
+
Delete
+
+
+
+
+
+
+
+
My Workers
+
New Worker
+
+
+
+
+
+
+
+ Delete
+
+
+
+
+
+
+
+
+
My Websites
+
New Website
+
+
+
+
+
+
+
+ Delete
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+