Frank před 9 měsíci
rodič
revize
83974e0c95

+ 3 - 0
app/.gitignore

@@ -0,0 +1,3 @@
+.env
+node_modules
+.sst

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1034 - 0
app/bun.lock


+ 36 - 0
app/infra/app.ts

@@ -0,0 +1,36 @@
+const bucket = new sst.cloudflare.Bucket("Bucket")
+
+export const api = new sst.cloudflare.Worker("Api", {
+  handler: "packages/function/src/api.ts",
+  url: true,
+  link: [bucket],
+  transform: {
+    worker: (args) => {
+      args.bindings = $resolve(args.bindings).apply((bindings) => [
+        ...bindings,
+        {
+          name: "SYNC_SERVER",
+          type: "durable_object_namespace",
+          className: "SyncServer",
+        },
+      ])
+      args.migrations = {
+        oldTag: "v1",
+        newTag: "v1",
+        //newSqliteClasses: ["SyncServer"],
+      }
+    },
+  },
+})
+
+new sst.cloudflare.StaticSite("Web", {
+  path: "packages/web",
+  environment: {
+    VITE_API_URL: api.url,
+  },
+  errorPage: "fallback.html",
+  build: {
+    command: "bun run build",
+    output: "dist/client",
+  },
+})

+ 37 - 0
app/package.json

@@ -0,0 +1,37 @@
+{
+  "name": "opencontrol",
+  "private": true,
+  "type": "module",
+  "packageManager": "bun",
+  "description": "OpenCode",
+  "scripts": {},
+  "workspaces": [
+    "packages/*"
+  ],
+  "devDependencies": {
+    "@tsconfig/node22": "22.0.0",
+    "@types/node": "^22.13.9",
+    "prettier": "^3.5.3",
+    "sst": "3.16.0",
+    "typescript": "5.8.2"
+  },
+  "engines": {
+    "bun": ">=1.0.0",
+    "node": ">=18.0.0"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/sst/opencode"
+  },
+  "license": "MIT",
+  "prettier": {
+    "semi": false
+  },
+  "overrides": {
+    "zod": "3.24.2"
+  },
+  "trustedDependencies": [
+    "esbuild",
+    "protobufjs"
+  ]
+}

+ 10 - 0
app/packages/function/package.json

@@ -0,0 +1,10 @@
+{
+  "name": "@opencode/function",
+  "version": "0.0.1",
+  "$schema": "https://json.schemastore.org/package.json",
+  "private": true,
+  "type": "module",
+  "devDependencies": {
+    "@cloudflare/workers-types": "^4.20250522.0"
+  }
+}

+ 167 - 0
app/packages/function/src/api.ts

@@ -0,0 +1,167 @@
+import { DurableObject } from "cloudflare:workers"
+import {
+  DurableObjectNamespace,
+  ExecutionContext,
+} from "@cloudflare/workers-types"
+import { createHash } from "node:crypto"
+import path from "node:path"
+import { Resource } from "sst"
+
+type Bindings = {
+  SYNC_SERVER: DurableObjectNamespace<WebSocketHibernationServer>
+}
+
+export class SyncServer extends DurableObject {
+  private files: Map<string, string> = new Map()
+
+  constructor(ctx, env) {
+    super(ctx, env)
+    this.ctx.blockConcurrencyWhile(async () => {
+      this.files = await this.ctx.storage.list()
+    })
+  }
+
+  async publish(filename: string, content: string) {
+    console.log(
+      "SyncServer publish",
+      filename,
+      content,
+      "to",
+      this.ctx.getWebSockets().length,
+      "subscribers",
+    )
+    this.files.set(filename, content)
+    await this.ctx.storage.put(filename, content)
+
+    this.ctx.getWebSockets().forEach((client) => {
+      client.send(JSON.stringify({ filename, content }))
+    })
+  }
+
+  async webSocketMessage(ws, message) {
+    if (message === "load_history") {
+    }
+  }
+
+  async webSocketClose(ws, code, reason, wasClean) {
+    ws.close(code, "Durable Object is closing WebSocket")
+  }
+
+  async fetch(req: Request) {
+    console.log("SyncServer subscribe")
+
+    // Creates two ends of a WebSocket connection.
+    const webSocketPair = new WebSocketPair()
+    const [client, server] = Object.values(webSocketPair)
+
+    this.ctx.acceptWebSocket(server)
+
+    setTimeout(() => {
+      this.files.forEach((content, filename) =>
+        server.send(JSON.stringify({ filename, content })),
+      )
+    }, 0)
+
+    return new Response(null, {
+      status: 101,
+      webSocket: client,
+    })
+  }
+}
+
+export default {
+  async fetch(request: Request, env: Bindings, ctx: ExecutionContext) {
+    const url = new URL(request.url)
+
+    if (request.method === "GET" && url.pathname === "/") {
+      return new Response("Hello, world!", {
+        headers: { "Content-Type": "text/plain" },
+      })
+    }
+    if (request.method === "POST" && url.pathname.endsWith("/share_create")) {
+      const body = await request.json()
+      const sessionID = body.session_id
+      const shareID = createHash("sha256").update(sessionID).digest("hex")
+      const infoFile = `${shareID}/info/${sessionID}.json`
+      const ret = await Resource.Bucket.get(infoFile)
+      if (ret)
+        return new Response("Error: Session already sharing", { status: 400 })
+
+      await Resource.Bucket.put(infoFile, "")
+
+      return new Response(JSON.stringify({ share_id: shareID }), {
+        headers: { "Content-Type": "application/json" },
+      })
+    }
+    if (request.method === "POST" && url.pathname.endsWith("/share_delete")) {
+      const body = await request.json()
+      const sessionID = body.session_id
+      const shareID = body.share_id
+      const infoFile = `${shareID}/info/${sessionID}.json`
+      await Resource.Bucket.delete(infoFile)
+      return new Response(JSON.stringify({}), {
+        headers: { "Content-Type": "application/json" },
+      })
+    }
+    if (request.method === "POST" && url.pathname.endsWith("/share_sync")) {
+      const body = await request.json()
+      const sessionID = body.session_id
+      const shareID = body.share_id
+      const filename = body.filename
+      const content = body.content
+
+      // validate filename
+      if (!filename.startsWith("info/") && !filename.startsWith("message/"))
+        return new Response("Error: Invalid filename", { status: 400 })
+
+      const infoFile = `${shareID}/info/${sessionID}.json`
+      const ret = await Resource.Bucket.get(infoFile)
+      if (!ret)
+        return new Response("Error: Session not shared", { status: 400 })
+
+      // send message to server
+      const id = env.SYNC_SERVER.idFromName(sessionID)
+      const stub = env.SYNC_SERVER.get(id)
+      await stub.publish(filename, content)
+
+      // store message
+      await Resource.Bucket.put(`${shareID}/${filename}`, content)
+
+      return new Response(JSON.stringify({}), {
+        headers: { "Content-Type": "application/json" },
+      })
+    }
+    if (request.method === "GET" && url.pathname.endsWith("/share_poll")) {
+      // Expect to receive a WebSocket Upgrade request.
+      // If there is one, accept the request and return a WebSocket Response.
+      const upgradeHeader = request.headers.get("Upgrade")
+      if (!upgradeHeader || upgradeHeader !== "websocket") {
+        return new Response("Error: Upgrade header is required", {
+          status: 426,
+        })
+      }
+
+      // get query parameters
+      const shareID = url.searchParams.get("share_id")
+      if (!shareID)
+        return new Response("Error: Share ID is required", { status: 400 })
+
+      // Get session ID
+      const listRet = await Resource.Bucket.list({
+        prefix: `${shareID}/info/`,
+        delimiter: "/",
+      })
+
+      if (listRet.objects.length === 0)
+        return new Response("Error: Session not shared", { status: 400 })
+      if (listRet.objects.length > 1)
+        return new Response("Error: Multiple sessions found", { status: 400 })
+      const sessionID = path.parse(listRet.objects[0].key).name
+
+      // subscribe to server
+      const id = env.SYNC_SERVER.idFromName(sessionID)
+      const stub = env.SYNC_SERVER.get(id)
+      return stub.fetch(request)
+    }
+  },
+}

+ 25 - 0
app/packages/function/sst-env.d.ts

@@ -0,0 +1,25 @@
+/* This file is auto-generated by SST. Do not edit. */
+/* tslint:disable */
+/* eslint-disable */
+/* deno-fmt-ignore-file */
+
+import "sst"
+declare module "sst" {
+  export interface Resource {
+    "Web": {
+      "type": "sst.cloudflare.StaticSite"
+      "url": string
+    }
+  }
+}
+// cloudflare 
+import * as cloudflare from "@cloudflare/workers-types";
+declare module "sst" {
+  export interface Resource {
+    "Api": cloudflare.Service
+    "Bucket": cloudflare.R2Bucket
+  }
+}
+
+import "sst"
+export {}

+ 8 - 0
app/packages/function/tsconfig.json

@@ -0,0 +1,8 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "extends": "@tsconfig/node22/tsconfig.json",
+  "compilerOptions": {
+    "module": "ESNext",
+    "moduleResolution": "bundler"
+  }
+}

+ 28 - 0
app/packages/web/.gitignore

@@ -0,0 +1,28 @@
+dist
+.wrangler
+.output
+.vercel
+.netlify
+.vinxi
+app.config.timestamp_*.js
+
+# Environment
+.env
+.env*.local
+
+# dependencies
+/node_modules
+
+# IDEs and editors
+/.idea
+.project
+.classpath
+*.launch
+.settings/
+
+# Temp
+gitignore
+
+# System Files
+.DS_Store
+Thumbs.db

+ 32 - 0
app/packages/web/README.md

@@ -0,0 +1,32 @@
+# SolidStart
+
+Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com);
+
+## Creating a project
+
+```bash
+# create a new project in the current directory
+npm init solid@latest
+
+# create a new project in my-app
+npm init solid@latest my-app
+```
+
+## Developing
+
+Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
+
+```bash
+npm run dev
+
+# or start the server and open the app in a new browser tab
+npm run dev -- --open
+```
+
+## Building
+
+Solid apps are built with _presets_, which optimise your project for deployment to different environments.
+
+By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`.
+
+## This project was created with the [Solid CLI](https://solid-cli.netlify.app)

+ 3 - 0
app/packages/web/app.config.ts

@@ -0,0 +1,3 @@
+import { defineConfig } from "@solidjs/start/config";
+
+export default defineConfig({});

+ 20 - 0
app/packages/web/package.json

@@ -0,0 +1,20 @@
+{
+  "name": "example-basic",
+  "type": "module",
+  "scripts": {
+    "dev": "vinxi dev",
+    "build": "vinxi build",
+    "start": "vinxi start",
+    "version": "vinxi version"
+  },
+  "dependencies": {
+    "@solidjs/meta": "^0.29.4",
+    "@solidjs/router": "^0.15.0",
+    "@solidjs/start": "^1.1.0",
+    "solid-js": "^1.9.5",
+    "vinxi": "^0.5.3"
+  },
+  "engines": {
+    "node": ">=22"
+  }
+}

binární
app/packages/web/public/favicon.ico


+ 39 - 0
app/packages/web/src/app.css

@@ -0,0 +1,39 @@
+body {
+  font-family: Gordita, Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
+}
+
+a {
+  margin-right: 1rem;
+}
+
+main {
+  text-align: center;
+  padding: 1em;
+  margin: 0 auto;
+}
+
+h1 {
+  color: #335d92;
+  text-transform: uppercase;
+  font-size: 4rem;
+  font-weight: 100;
+  line-height: 1.1;
+  margin: 4rem auto;
+  max-width: 14rem;
+}
+
+p {
+  max-width: 14rem;
+  margin: 2rem auto;
+  line-height: 1.35;
+}
+
+@media (min-width: 480px) {
+  h1 {
+    max-width: none;
+  }
+
+  p {
+    max-width: none;
+  }
+}

+ 20 - 0
app/packages/web/src/app.tsx

@@ -0,0 +1,20 @@
+import { MetaProvider, Title } from "@solidjs/meta"
+import { Router } from "@solidjs/router"
+import { FileRoutes } from "@solidjs/start/router"
+import { Suspense } from "solid-js"
+import "./app.css"
+
+export default function App() {
+  return (
+    <Router
+      root={(props) => (
+        <MetaProvider>
+          <Title>SolidStart - Basic</Title>
+          <Suspense>{props.children}</Suspense>
+        </MetaProvider>
+      )}
+    >
+      <FileRoutes />
+    </Router>
+  )
+}

+ 4 - 0
app/packages/web/src/entry-client.tsx

@@ -0,0 +1,4 @@
+// @refresh reload
+import { mount, StartClient } from "@solidjs/start/client";
+
+mount(() => <StartClient />, document.getElementById("app")!);

+ 21 - 0
app/packages/web/src/entry-server.tsx

@@ -0,0 +1,21 @@
+// @refresh reload
+import { createHandler, StartServer } from "@solidjs/start/server";
+
+export default createHandler(() => (
+  <StartServer
+    document={({ assets, children, scripts }) => (
+      <html lang="en">
+        <head>
+          <meta charset="utf-8" />
+          <meta name="viewport" content="width=device-width, initial-scale=1" />
+          <link rel="icon" href="/favicon.ico" />
+          {assets}
+        </head>
+        <body>
+          <div id="app">{children}</div>
+          {scripts}
+        </body>
+      </html>
+    )}
+  />
+));

+ 1 - 0
app/packages/web/src/global.d.ts

@@ -0,0 +1 @@
+/// <reference types="@solidjs/start/env" />

+ 19 - 0
app/packages/web/src/routes/[...404].tsx

@@ -0,0 +1,19 @@
+import { Title } from "@solidjs/meta";
+import { HttpStatusCode } from "@solidjs/start";
+
+export default function NotFound() {
+  return (
+    <main>
+      <Title>Not Found</Title>
+      <HttpStatusCode code={404} />
+      <h1>Page Not Found</h1>
+      <p>
+        Visit{" "}
+        <a href="https://start.solidjs.com" target="_blank">
+          start.solidjs.com
+        </a>{" "}
+        to learn how to build SolidStart apps.
+      </p>
+    </main>
+  );
+}

+ 14 - 0
app/packages/web/src/routes/index.tsx

@@ -0,0 +1,14 @@
+import { Title } from "@solidjs/meta"
+import { A } from "@solidjs/router"
+
+export default function Home() {
+  return (
+    <main>
+      <Title>Share Demo</Title>
+      <h1>Share Demo</h1>
+      <p>
+        <A href="/share/test-share-id">Go to test share</A>
+      </p>
+    </main>
+  )
+}

+ 150 - 0
app/packages/web/src/routes/share/[id].tsx

@@ -0,0 +1,150 @@
+import { Title } from "@solidjs/meta"
+import { createSignal, onCleanup, onMount, Show, For } from "solid-js"
+import { useParams } from "@solidjs/router"
+
+type Message = {
+  filename: string
+  content: string
+}
+
+export default function SharePage() {
+  const params = useParams<{ id: string }>()
+  const [messages, setMessages] = createSignal<Message[]>([])
+  const [connectionStatus, setConnectionStatus] = createSignal("Disconnected")
+
+  onMount(() => {
+    // Get the API URL from environment
+    const apiUrl = import.meta.env.VITE_API_URL
+    const shareId = params.id
+
+    console.log("Mounting Share component with ID:", shareId)
+    console.log("API URL:", apiUrl)
+
+    if (!apiUrl) {
+      console.error("API URL not found in environment variables")
+      setConnectionStatus("Error: API URL not found")
+      return
+    }
+
+    let reconnectTimer: number | undefined
+    let socket: WebSocket | null = null
+
+    // Function to create and set up WebSocket with auto-reconnect
+    const setupWebSocket = () => {
+      // Close any existing connection
+      if (socket) {
+        socket.close()
+      }
+
+      setConnectionStatus("Connecting...")
+
+      // Always use secure WebSocket protocol (wss)
+      const wsBaseUrl = apiUrl.replace(/^https?:\/\//, 'wss://')
+      const wsUrl = `${wsBaseUrl}/share_poll?share_id=${shareId}`
+      console.log("Connecting to WebSocket URL:", wsUrl)
+
+      // Create WebSocket connection
+      socket = new WebSocket(wsUrl)
+
+      // Handle connection opening
+      socket.onopen = () => {
+        setConnectionStatus("Connected")
+        console.log("WebSocket connection established")
+      }
+
+      // Handle incoming messages
+      socket.onmessage = (event) => {
+        console.log("WebSocket message received")
+        try {
+          const data = JSON.parse(event.data) as Message
+          setMessages((prev) => [...prev, data])
+        } catch (error) {
+          console.error("Error parsing WebSocket message:", error)
+        }
+      }
+
+      // Handle errors
+      socket.onerror = (error) => {
+        console.error("WebSocket error:", error)
+        setConnectionStatus("Error: Connection failed")
+      }
+
+      // Handle connection close and reconnection
+      socket.onclose = (event) => {
+        console.log(`WebSocket closed: ${event.code} ${event.reason}`)
+        setConnectionStatus("Disconnected, reconnecting...")
+
+        // Try to reconnect after 2 seconds
+        clearTimeout(reconnectTimer)
+        reconnectTimer = window.setTimeout(
+          setupWebSocket,
+          2000
+        ) as unknown as number
+      }
+    }
+
+    // Initial connection
+    setupWebSocket()
+
+    // Clean up on component unmount
+    onCleanup(() => {
+      console.log("Cleaning up WebSocket connection")
+      if (socket) {
+        socket.close()
+      }
+      clearTimeout(reconnectTimer)
+    })
+  })
+
+  return (
+    <main>
+      <Title>Share: {params.id}</Title>
+      <h1>Share: {params.id}</h1>
+
+      <div style={{ margin: "2rem 0" }}>
+        <h2>WebSocket Connection</h2>
+        <p>
+          Status: <strong>{connectionStatus()}</strong>
+        </p>
+
+        <h3>Live Updates</h3>
+        <div
+          style={{
+            border: "1px solid #ccc",
+            padding: "1rem",
+            borderRadius: "0.5rem",
+            maxHeight: "500px",
+            overflowY: "auto",
+          }}
+        >
+          <Show
+            when={messages().length > 0}
+            fallback={<p>Waiting for messages...</p>}
+          >
+            <ul style={{ listStyleType: "none", padding: 0 }}>
+              <For each={messages()}>
+                {(msg) => (
+                  <li
+                    style={{
+                      padding: "0.5rem",
+                      margin: "0.5rem 0",
+                      backgroundColor: "#f5f5f5",
+                      borderRadius: "0.25rem",
+                    }}
+                  >
+                    <div>
+                      <strong>Filename:</strong> {msg.filename}
+                    </div>
+                    <div>
+                      <strong>Content:</strong> {msg.content}
+                    </div>
+                  </li>
+                )}
+              </For>
+            </ul>
+          </Show>
+        </div>
+      </div>
+    </main>
+  )
+}

+ 9 - 0
app/packages/web/sst-env.d.ts

@@ -0,0 +1,9 @@
+/* This file is auto-generated by SST. Do not edit. */
+/* tslint:disable */
+/* eslint-disable */
+/* deno-fmt-ignore-file */
+
+/// <reference path="../../sst-env.d.ts" />
+
+import "sst"
+export {}

+ 19 - 0
app/packages/web/tsconfig.json

@@ -0,0 +1,19 @@
+{
+  "compilerOptions": {
+    "target": "ESNext",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "allowSyntheticDefaultImports": true,
+    "esModuleInterop": true,
+    "jsx": "preserve",
+    "jsxImportSource": "solid-js",
+    "allowJs": true,
+    "strict": true,
+    "noEmit": true,
+    "types": ["vinxi/types/client"],
+    "isolatedModules": true,
+    "paths": {
+      "~/*": ["./src/*"]
+    }
+  }
+}

+ 24 - 0
app/sst-env.d.ts

@@ -0,0 +1,24 @@
+/* This file is auto-generated by SST. Do not edit. */
+/* tslint:disable */
+/* eslint-disable */
+/* deno-fmt-ignore-file */
+
+declare module "sst" {
+  export interface Resource {
+    "Api": {
+      "type": "sst.cloudflare.Worker"
+      "url": string
+    }
+    "Bucket": {
+      "type": "sst.cloudflare.Bucket"
+    }
+    "Web": {
+      "type": "sst.cloudflare.StaticSite"
+      "url": string
+    }
+  }
+}
+/// <reference path="sst-env.d.ts" />
+
+import "sst"
+export {}

+ 26 - 0
app/sst.config.ts

@@ -0,0 +1,26 @@
+/// <reference path="./.sst/platform/config.d.ts" />
+
+export default $config({
+  app(input) {
+    return {
+      name: "opencode",
+      removal: input?.stage === "production" ? "retain" : "remove",
+      protect: ["production"].includes(input?.stage),
+      home: "cloudflare",
+      providers: {
+        cloudflare: {
+          apiToken:
+            input?.stage === "production"
+              ? process.env.PRODUCTION_CLOUDFLARE_API_TOKEN
+              : process.env.DEV_CLOUDFLARE_API_TOKEN,
+        },
+      },
+    }
+  },
+  async run() {
+    const { api } = await import("./infra/app.js")
+    return {
+      api: api.url,
+    }
+  },
+})

+ 1 - 0
app/tsconfig.json

@@ -0,0 +1 @@
+{}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů