diff --git a/packages/app/src/app/lib/publisher.ts b/packages/app/src/app/lib/publisher.ts index 31707d15..b8985f6c 100644 --- a/packages/app/src/app/lib/publisher.ts +++ b/packages/app/src/app/lib/publisher.ts @@ -4,7 +4,10 @@ export type PublishBundleResult = { url: string; }; -export const DEFAULT_OPENWORK_PUBLISHER_BASE_URL = "https://share.openwork.software"; +const ENV_OPENWORK_PUBLISHER_BASE_URL = String(import.meta.env.VITE_OPENWORK_PUBLISHER_BASE_URL ?? "").trim(); + +export const DEFAULT_OPENWORK_PUBLISHER_BASE_URL = + ENV_OPENWORK_PUBLISHER_BASE_URL || "https://share.openwork.software"; function normalizeBaseUrl(input: string): string { const trimmed = String(input ?? "").trim(); diff --git a/packaging/docker/README.md b/packaging/docker/README.md index babfd9df..d9eea33f 100644 --- a/packaging/docker/README.md +++ b/packaging/docker/README.md @@ -33,10 +33,13 @@ Optional env vars (via `.env` or `export`): - `OPENWORK_WORKSPACE` — host path to mount as workspace - `OPENWORK_PORT` — host port to map to container :8787 - `WEB_PORT` — host port to map to container :5173 +- `SHARE_PORT` — host port to map to the local share service :3000 - `OPENWORK_DOCKER_DEV_MOUNT_HOST_OPENCODE=1` — import host OpenCode config/auth into the isolated dev state - `OPENWORK_OPENCODE_CONFIG_DIR` — override the host OpenCode config source used for that optional import - `OPENWORK_OPENCODE_DATA_DIR` — override the host OpenCode data source used for that optional import +The dev stack also starts the local share service automatically and points the OpenWork app at it, so share-link flows publish to a local service instead of `https://share.openwork.software`. + --- ## Den local stack (Docker) diff --git a/packaging/docker/dev-up.sh b/packaging/docker/dev-up.sh index f1ecda09..5493457d 100755 --- a/packaging/docker/dev-up.sh +++ b/packaging/docker/dev-up.sh @@ -13,6 +13,7 @@ set -euo pipefail # Outputs: # - Web UI URL # - OpenWork server URL +# - Share service URL # - Token file path ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -140,10 +141,15 @@ WEB_PORT="$(pick_port)" if [ "$WEB_PORT" = "$OPENWORK_PORT" ]; then WEB_PORT="$(pick_port)" fi +SHARE_PORT="${SHARE_PORT:-$(pick_port)}" +if [ "$SHARE_PORT" = "$OPENWORK_PORT" ] || [ "$SHARE_PORT" = "$WEB_PORT" ]; then + SHARE_PORT="$(pick_port)" +fi echo "Starting Docker Compose project: $PROJECT" >&2 echo "- OPENWORK_PORT=$OPENWORK_PORT" >&2 echo "- WEB_PORT=$WEB_PORT" >&2 +echo "- SHARE_PORT=$SHARE_PORT" >&2 echo "- OPENWORK_DEV_MODE=1" >&2 if [ "$MOUNT_HOST_OPENCODE" = "1" ]; then echo "- Host OpenCode import: enabled" >&2 @@ -154,7 +160,7 @@ fi start_stack() { local config_dir="$1" local data_dir="$2" - OPENWORK_DEV_ID="$DEV_ID" OPENWORK_PORT="$OPENWORK_PORT" WEB_PORT="$WEB_PORT" \ + OPENWORK_DEV_ID="$DEV_ID" OPENWORK_PORT="$OPENWORK_PORT" WEB_PORT="$WEB_PORT" SHARE_PORT="$SHARE_PORT" \ OPENWORK_DEV_MODE="1" \ OPENWORK_HOST_OPENCODE_CONFIG_DIR="$config_dir" \ OPENWORK_HOST_OPENCODE_DATA_DIR="$data_dir" \ @@ -184,6 +190,7 @@ fi echo "" >&2 echo "OpenWork web UI: http://localhost:$WEB_PORT" >&2 echo "OpenWork server: http://localhost:$OPENWORK_PORT" >&2 +echo "Share service: http://localhost:$SHARE_PORT" >&2 echo "Token file: $ROOT_DIR/tmp/.dev-env-$DEV_ID" >&2 echo "" >&2 echo "To stop this stack:" >&2 diff --git a/packaging/docker/docker-compose.dev.yml b/packaging/docker/docker-compose.dev.yml index 2b7de6a4..f97228fd 100644 --- a/packaging/docker/docker-compose.dev.yml +++ b/packaging/docker/docker-compose.dev.yml @@ -11,6 +11,7 @@ # OPENWORK_WORKSPACE — host path to mount as workspace (default: ./workspace) # OPENWORK_PORT — host port to map to container :8787 (default: 8787) # WEB_PORT — host port to map to container :5173 (default: 5173) +# SHARE_PORT — host port to map to the share service :3000 (default: 3006) # OPENWORK_DEV_ID — unique ID for this stack (default: default) # OPENWORK_DEV_MODE — enables isolated OpenCode dev state (set by dev-up.sh) # OPENWORK_DOCKER_DEV_MOUNT_HOST_OPENCODE=1 — import host OpenCode config/auth into the isolated dev state @@ -151,6 +152,8 @@ services: depends_on: orchestrator: condition: service_healthy + share: + condition: service_healthy entrypoint: ["/bin/sh", "-c"] command: - | @@ -184,6 +187,7 @@ services: export VITE_OPENWORK_URL="http://localhost:${OPENWORK_PORT:-8787}" export VITE_OPENWORK_PORT="${OPENWORK_PORT:-8787}" + export VITE_OPENWORK_PUBLISHER_BASE_URL="http://localhost:${SHARE_PORT:-3006}" export VITE_ALLOWED_HOSTS="all" export HOST="0.0.0.0" export PORT="5173" @@ -197,6 +201,49 @@ services: environment: OPENWORK_DEV_ID: ${OPENWORK_DEV_ID:-default} + share: + <<: *shared + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + + apt-get update -qq && apt-get install -y -qq --no-install-recommends \ + curl ca-certificates >/dev/null 2>&1 + + corepack enable && corepack prepare pnpm@10.27.0 --activate + + echo "[share] Installing dependencies..." + pnpm install --no-frozen-lockfile --network-concurrency 1 --child-concurrency 1 + + mkdir -p /app/tmp/share-service-blobs + + echo "[share] Building Next app..." + pnpm --dir services/openwork-share build + + echo "" + echo "============================================" + echo " OpenWork share service" + echo " URL: http://localhost:${SHARE_PORT:-3006}" + echo "============================================" + echo "" + + exec pnpm --dir services/openwork-share exec next start --hostname 0.0.0.0 --port 3000 + ports: + - "${SHARE_PORT:-3006}:3000" + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || exit 1"] + interval: 5s + timeout: 10s + retries: 30 + start_period: 180s + environment: + CI: "true" + OPENWORK_DEV_MODE: ${OPENWORK_DEV_MODE:-1} + LOCAL_BLOB_DIR: /app/tmp/share-service-blobs + PUBLIC_BASE_URL: http://localhost:${SHARE_PORT:-3006} + PUBLIC_OPENWORK_APP_URL: http://localhost:${WEB_PORT:-5173} + volumes: pnpm-store: name: openwork-dev-pnpm-store diff --git a/services/openwork-share/README.md b/services/openwork-share/README.md index 3b3f8a81..e61287f9 100644 --- a/services/openwork-share/README.md +++ b/services/openwork-share/README.md @@ -72,6 +72,10 @@ The packager rejects files that appear to contain secrets in shareable config. - Default: `https://app.openwork.software` - Target app URL for the Open in app action on bundle pages. +- `LOCAL_BLOB_DIR` + - Optional local filesystem storage root for bundle JSON. + - When `BLOB_READ_WRITE_TOKEN` is unset in local/dev mode, the service falls back to local file storage automatically. + ## Local development For local testing you can use: @@ -83,6 +87,8 @@ pnpm --dir services/openwork-share dev Open `http://localhost:3000`. +Without a `BLOB_READ_WRITE_TOKEN`, local development now stores bundles on disk in a local dev blob directory so publishing works out of the box. + ## Deploy Recommended project settings: diff --git a/services/openwork-share/server/_lib/blob-store.ts b/services/openwork-share/server/_lib/blob-store.ts index b80b9373..2a18f0b5 100644 --- a/services/openwork-share/server/_lib/blob-store.ts +++ b/services/openwork-share/server/_lib/blob-store.ts @@ -1,11 +1,79 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + import { head, put } from "@vercel/blob"; import { ulid } from "ulid"; import type { FetchBundleResult, StoreBundleResult } from "./types.ts"; -export async function storeBundleJson(rawJson: string): Promise { +function resolveLocalBlobDir(): string | null { + const explicitDir = String(process.env.LOCAL_BLOB_DIR ?? "").trim(); + if (explicitDir) { + return explicitDir; + } + + if (String(process.env.BLOB_READ_WRITE_TOKEN ?? "").trim()) { + return null; + } + + const isDevLike = + String(process.env.OPENWORK_DEV_MODE ?? "") === "1" || + String(process.env.NODE_ENV ?? "").trim() !== "production"; + + if (!isDevLike) { + return null; + } + + return path.resolve(process.cwd(), ".openwork-share-blobs"); +} + +function resolveBundlePathname(id: string): string { + return `bundles/${id}.json`; +} + +async function storeBundleJsonLocally(rawJson: string): Promise { const id = ulid(); - const pathname = `bundles/${id}.json`; + const pathname = resolveBundlePathname(id); + const localBlobDir = resolveLocalBlobDir(); + + if (!localBlobDir) { + throw new Error("Local blob storage is not configured"); + } + + const targetPath = path.join(localBlobDir, pathname); + await mkdir(path.dirname(targetPath), { recursive: true }); + await writeFile(targetPath, Buffer.from(String(rawJson), "utf8")); + + return { id, pathname }; +} + +async function fetchBundleJsonLocally(id: string): Promise { + const pathname = resolveBundlePathname(id); + const localBlobDir = resolveLocalBlobDir(); + + if (!localBlobDir) { + throw new Error("Local blob storage is not configured"); + } + + const targetPath = path.join(localBlobDir, pathname); + const rawBuffer = await readFile(targetPath); + return { + blob: { + url: `file://${targetPath}`, + contentType: "application/json", + }, + rawBuffer, + rawJson: rawBuffer.toString("utf8"), + }; +} + +export async function storeBundleJson(rawJson: string): Promise { + if (resolveLocalBlobDir()) { + return storeBundleJsonLocally(rawJson); + } + + const id = ulid(); + const pathname = resolveBundlePathname(id); const buffer = Buffer.from(String(rawJson), "utf8"); await put(pathname, buffer, { @@ -18,7 +86,11 @@ export async function storeBundleJson(rawJson: string): Promise { - const pathname = `bundles/${id}.json`; + if (resolveLocalBlobDir()) { + return fetchBundleJsonLocally(id); + } + + const pathname = resolveBundlePathname(id); const blob = await head(pathname); const response = await fetch(blob.url, { method: "GET" });