feat: externalize buttplug as separate nginx container

- Add Dockerfile.buttplug: builds Rust/WASM + TS, serves via nginx
- Add nginx.buttplug.conf: serves /dist and /wasm with correct MIME types
- Add .gitea/workflows/docker-build-buttplug.yml: path-filtered CI workflow
- Strip Rust toolchain and buttplug build from frontend Dockerfile
- Move buttplug to devDependencies (types only at build time)
- Remove vite-plugin-wasm from frontend (WASM now served by nginx)
- Add /buttplug proxy in vite.config (dev: localhost:8080)
- Add buttplug service to compose.yml
- Load buttplug dynamically in play page via runtime import
- Fix faq page: suppress no-unnecessary-state-wrap for reassigned SvelteSet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 13:49:38 +01:00
parent 239128bf5e
commit f880aa5957
10 changed files with 220 additions and 64 deletions

View File

@@ -12,6 +12,7 @@
"check": "svelte-check --tsconfig ./tsconfig.json --threshold warning"
},
"devDependencies": {
"@sexy.pivoine.art/buttplug": "workspace:*",
"@iconify-json/ri": "^1.2.10",
"@iconify/tailwind4": "^1.2.1",
"@internationalized/date": "^3.11.0",
@@ -42,7 +43,6 @@
"vite-plugin-wasm": "3.5.0"
},
"dependencies": {
"@sexy.pivoine.art/buttplug": "workspace:*",
"@sexy.pivoine.art/types": "workspace:*",
"graphql": "^16.11.0",
"graphql-request": "^7.1.2",

View File

@@ -8,7 +8,8 @@
import Meta from "$lib/components/meta/meta.svelte";
let searchQuery = $state("");
let expandedItems = new SvelteSet<number>();
// eslint-disable-next-line svelte/no-unnecessary-state-wrap -- variable is reassigned, $state is required
let expandedItems = $state(new SvelteSet<number>());
const faqCategories = [
{

View File

@@ -1,14 +1,7 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import Meta from "$lib/components/meta/meta.svelte";
import {
ButtplugClient,
ButtplugWasmClientConnector,
type ButtplugClientDevice,
type OutputType,
InputType,
DeviceOutputValueConstructor,
} from "@sexy.pivoine.art/buttplug";
import type * as ButtplugTypes from "@sexy.pivoine.art/buttplug";
import Button from "$lib/components/ui/button/button.svelte";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
@@ -19,8 +12,13 @@
import { toast } from "svelte-sonner";
import SexyBackground from "$lib/components/background/background.svelte";
const client = new ButtplugClient("Sexy.Art");
let connected = $state(client.connected);
// Runtime buttplug values — loaded dynamically from the buttplug nginx container
let client: ButtplugTypes.ButtplugClient;
let InputType: typeof ButtplugTypes.InputType;
let DeviceOutputValueConstructor: typeof ButtplugTypes.DeviceOutputValueConstructor;
let ButtplugWasmClientConnector: typeof ButtplugTypes.ButtplugWasmClientConnector;
let connected = $state(false);
let scanning = $state(false);
let devices = $state<BluetoothDevice[]>([]);
@@ -45,7 +43,7 @@
// await ButtplugWasmClientConnector.activateLogging("info");
await client.connect(connector);
client.on("deviceadded", onDeviceAdded);
client.on("deviceremoved", (dev: ButtplugClientDevice) => {
client.on("deviceremoved", (dev: ButtplugTypes.ButtplugClientDevice) => {
const idx = devices.findIndex((d) => d.info.index === dev.index);
if (idx !== -1) devices.splice(idx, 1);
});
@@ -59,7 +57,7 @@
scanning = true;
}
async function onDeviceAdded(dev: ButtplugClientDevice) {
async function onDeviceAdded(dev: ButtplugTypes.ButtplugClientDevice) {
const device = convertDevice(dev);
devices.push(device);
@@ -93,7 +91,7 @@
if (!feature) return;
actuator.value = value;
const outputType = actuator.outputType as typeof OutputType;
const outputType = actuator.outputType as typeof ButtplugTypes.OutputType;
await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value));
// Capture event if recording
@@ -141,7 +139,7 @@
device.actuators.forEach((a) => (a.value = 0));
}
function convertDevice(device: ButtplugClientDevice): BluetoothDevice {
function convertDevice(device: ButtplugTypes.ButtplugClientDevice): BluetoothDevice {
const actuators: import("$lib/types").DeviceActuator[] = []; // eslint-disable-line @typescript-eslint/consistent-type-imports
for (const [, feature] of device.features) {
for (const outputType of feature.outputTypes) {
@@ -333,7 +331,7 @@
// Send command to device via feature
const feature = device.info.features.get(actuator.featureIndex);
if (feature) {
const outputType = actuator.outputType as typeof OutputType;
const outputType = actuator.outputType as typeof ButtplugTypes.OutputType;
feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue));
}
@@ -365,12 +363,20 @@
const { data } = $props();
onMount(() => {
if (data.authStatus.authenticated) {
init();
onMount(async () => {
if (!data.authStatus.authenticated) {
goto("/login");
return;
}
goto("/login");
// Concatenation prevents Rollup from statically resolving this URL at build time
const buttplugUrl = "/buttplug/" + "dist/index.js";
const bp = await import(/* @vite-ignore */ buttplugUrl);
InputType = bp.InputType;
DeviceOutputValueConstructor = bp.DeviceOutputValueConstructor;
ButtplugWasmClientConnector = bp.ButtplugWasmClientConnector;
client = new bp.ButtplugClient("Sexy.Art");
connected = client.connected;
await init();
});
</script>

View File

@@ -2,13 +2,17 @@ import path from "path";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import { sveltekit } from "@sveltejs/kit/vite";
import wasm from "vite-plugin-wasm";
export default defineConfig({
plugins: [sveltekit(), tailwindcss(), wasm()],
plugins: [sveltekit(), tailwindcss()],
resolve: {
alias: { $lib: path.resolve("./src/lib"), "@": path.resolve("./src/lib") },
},
build: {
rollupOptions: {
external: ["@sexy.pivoine.art/buttplug"],
},
},
server: {
port: 3000,
proxy: {
@@ -19,6 +23,11 @@ export default defineConfig({
secure: false,
ws: true,
},
"/buttplug": {
rewrite: (path) => path.replace(/^\/buttplug/, ""),
target: "http://localhost:8080",
changeOrigin: true,
},
},
},
});