Open7

Deno + Webview + Preact でデスクトップアプリ

nikogolinikogoli

1-1. 仕組み

基本的な流れは以下の通り

  1. アプリケーションの JSXファイルを作る
  2. preact を使ってこの JSX を hydrate するファイルを作る
  3. このファイルを基準に必要なファイルを bundle し、1つのスクリプトにまとめる
  4. ルートとなる JSXをつくり、1 のJSXを<body>に、3 のスクリプトを<script>にいれる
  5. このJSX を renderToString し、 HTML を作る
  6. この HTML を webview で開く



具体的な形式としては、以下の3点セットが基本となる

  1. App.tsx:アプリケーションの JSX (複数のファイルに分割してもよい)
    コードの例
    App.tsx
    /** @jsx h */
    import { h } from "https://esm.sh/preact@10.15.1"
    import { useState, useEffect } from "https://esm.sh/preact@10.15.1/hooks"
    
    
    export default function App(){
      const [time, setTime] = useState(new Date().toTimeString().split(" ")[0])
    
      useEffect(() => {
        const timer = setInterval(
          () => setTime(new Date().toTimeString().split(" ")[0]), 1000
        )
        return () => clearInterval(timer)
      })
    
      return (
        <div class='h-screen grid gap-6 place-content-center justify-items-center'>
          <span class='flex gap-3'>
            <span class='text-3xl'>Deno App</span>
          </span>
          <div class='text-2xl'>
            <span>{time}</span>
          </div>
        </div>
      )
    }
    
  2. Client.tsx:App.tsx を hydrate する JSX
    コードの例
    Client.tsx
    /** @jsx h */
    import { h, hydrate } from "https://esm.sh/preact@10.15.1"
    
    import App from "./App.tsx";
    
    hydrate( <App/>, document.body )
    
  3. main.tsx:バンドル処理 + ルートの JSX の作成 + renderToString + webview の起動を行う
    コードの例
    main.tsx
    /** @jsx h */
    import { h } from "https://esm.sh/preact@10.15.1"
    import { renderToString } from "https://esm.sh/preact-render-to-string@6.2.1?deps=preact@10.15.1"
    import { bundle } from "https://deno.land/x/emit@0.31.0/mod.ts"
    import { Webview, SizeHint } from "jsr:@webview/webview"
    
    import App from "./App.tsx"
    
    import { defineConfig } from "https://esm.sh/@twind/core@1.1.3";
    const TwindConfig =  {
      ...defineConfig({
        hash: false,
      })
    }
    const twind_config_script = `twind.install(${JSON.stringify(TwindConfig)})`
    
    
    const CRIENT_PATH = "Client.tsx"
    const script = await bundle(CRIENT_PATH).then(result => result.code)
    
    
    function View(){
      return(
        <html lang="en">
          <head>
            <meta charSet="utf-8"/>
            <script src="https://cdn.twind.style" crossOrigin="true"></script>
            <script dangerouslySetInnerHTML={{__html: twind_config_script}}></script>
            <script type="module" dangerouslySetInnerHTML={{__html: script}}></script>
          </head>
          <body>
            <App />
          </body>
        </html>
      )
    }
    
    const webview = new Webview(true, {width: 600, height: 400, hint: SizeHint.FIXED})
    webview.title = "Deno App"
    webview.navigate(`data:text/html, ${encodeURIComponent(renderToString(View()))}`)
    
    webview.run()
    
nikogolinikogoli

上記の3つのファイルと以下の deno.jsonを同じフォルダに入れて

deno.json
{
  "lock": false,
  "tasks": {
    "start": "deno run -A --unstable-ffi main.tsx"
  }
}

deno task start を実行すると (webviewが動くなら) 以下のようなウィンドウが立ち上がる

nikogolinikogoli

1-2. pragma なしの JSX ファイル

pragma/** @jsx h */ を省略する形式の JSX を使うこともできる
この場合、emitではなく fresh のように esbuild を使ってバンドルを行う

App.tsx の例

pragma 無しの場合、tabler icons tsxのような fresh 用のコンポーネントを利用できる

App.tsx
import { useState, useEffect } from "preact/hooks"
import IconBrandDeno from "https://deno.land/x/tabler_icons_tsx@0.0.5/tsx/brand-deno.tsx"

export default function App(){
  const [time, setTime] = useState(new Date().toTimeString().split(" ")[0])

  useEffect(() => {
    const timer = setInterval(
      () => setTime(new Date().toTimeString().split(" ")[0]), 1000
    )
    return () => clearInterval(timer)
  })

  return (
    <div class='h-screen grid gap-6 place-content-center justify-items-center'>
      <span class='flex gap-3'>
        <IconBrandDeno size={36} stroke-width={1} />
        <span class='text-3xl'>Deno App</span>
      </span>
      <div class='text-2xl'>
        <span>{time}</span>
      </div>
    </div>
  )
}
Client.tsx の例

pragma を削っただけ

import { hydrate } from "preact"

import App from "./App.tsx";

hydrate( <App/>, document.body )
main.tsx の例

esbuild を使う場合は import map が必要(のはず)なので、deno.jsonから import map を作成する処理も追加される。その他、fileURL に変換する処理や、バンドルで作成されたファイルを削除する処理なども増える。

main.tsx
import { renderToString } from "https://esm.sh/preact-render-to-string@6.2.1?deps=preact@10.15.1"
import * as esbuild from "https://deno.land/x/esbuild@v0.19.2/mod.js"
import { denoPlugins } from "https://deno.land/x/esbuild_deno_loader@0.8.2/mod.ts"
import { toFileUrl, resolve } from "https://deno.land/std@0.200.0/path/mod.ts"
import { Webview, SizeHint } from "jsr:@webview/webview"

import App from "./App.tsx"

import { defineConfig } from "https://esm.sh/@twind/core@1.1.3";
const TwindConfig =  {
  ...defineConfig({
    hash: false,
  })
}
const twind_config_script = `twind.install(${JSON.stringify(TwindConfig)})`


const CRIENT_PATH = "Client.tsx"
const _TEMP_MAP_NAME = "temp_map.json"

await Deno.readTextFile("./deno.json")
  .then(tx => JSON.parse(tx) as Record<string, Record<string, string>>).then(jdata => jdata.imports)
  .then(imports => {
    Deno.writeTextFile(_TEMP_MAP_NAME, JSON.stringify({imports}))
  })

const importMapURL = toFileUrl(resolve(_TEMP_MAP_NAME)).href

esbuild.initialize({})
const script = await esbuild.build({
  plugins: [ ...denoPlugins({importMapURL}) ],
  entryPoints: { main: toFileUrl(resolve(CRIENT_PATH)).href },
  bundle: true,
  format: "esm",
  platform: "neutral",
  outfile: "bundled.js",
  jsx: "automatic",
  jsxImportSource: "preact",
}).then((_result: unknown) => Deno.readTextFile("./bundled.js"))
esbuild.stop()

await Deno.remove("./bundled.js")
await Deno.remove(_TEMP_MAP_NAME)

function View(){
  return(
    <html lang="en">
      <head>
        <meta charSet="utf-8"/>
        <script src="https://cdn.twind.style" crossOrigin="true"></script>
        <script dangerouslySetInnerHTML={{__html: twind_config_script}}></script>
        <script type="module" dangerouslySetInnerHTML={{__html: script}}></script>
      </head>
      <body>
        <App />
      </body>
    </html>
  )
}

const webview = new Webview(true, {width: 600, height: 400, hint: SizeHint.FIXED})
webview.title = "Deno App"
webview.navigate(`data:text/html, ${encodeURIComponent(renderToString(View()))}`)

webview.run()
nikogolinikogoli

上記の3つのファイルと以下の deno.jsonを同じフォルダに入れて

deno.json
{
  "lock": false,
  "tasks": {
    "start": "deno run -A --unstable-ffi main.tsx"
  },
  "importMap": "./import_map.json",
  "imports": {
    "preact": "https://esm.sh/preact@10.15.1",
    "preact/": "https://esm.sh/preact@10.15.1/"
  },
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "preact"
  }
}

deno task start を実行すると (webviewが動くなら) 以下のようなウィンドウが立ち上がる

nikogolinikogoli

2. 複数のHTML間のルーティング

リンクを経由して複数のページ間を行き来する場合、WebWoker を使ってリクエストを捌く
そのため、以下のような流れになる

  1. アプリケーションのトップページの JSX を hydrate し、renderToString し、HTMLを作成
  2. この HTML を保存する
  3. WebWoker を起動し、deno でポート番号を指定して serve する
  4. WebView を起動し、3 で指定したポート番号の URL(http://localhost:8088/など)を開く
  5. worker は / へのリクエストに対し、2 で保存した HTML を読み込んで中身を返す
  6. 以降、woker ではリクエストに応じて、「 指定されたページの JSX を hydrate → renderToString → 作成されたHTML を返す」を繰り返す


挙動は↓のような感じ

nikogolinikogoli
main.tsx
main.tsx
import { renderToString } from "https://esm.sh/preact-render-to-string@6.2.1?deps=preact@10.15.1"
import * as esbuild from "https://deno.land/x/esbuild@v0.19.2/mod.js"
import { denoPlugins } from "https://deno.land/x/esbuild_deno_loader@0.8.2/mod.ts"
import { toFileUrl, resolve } from "https://deno.land/std@0.200.0/path/mod.ts"
import { join, dirname } from "https://deno.land/std@0.200.0/path/mod.ts"
import { Webview, SizeHint } from "jsr:@webview/webview"

import App from "./App.tsx"

import { defineConfig } from "https://esm.sh/@twind/core@1.1.3";
const TwindConfig =  {
  ...defineConfig({
    hash: false,
  })
}
const twind_config_script = `twind.install(${JSON.stringify(TwindConfig)})`


const CRIENT_PATH = "Client.tsx"
const _TEMP_MAP_NAME = "temp_map.json"

await Deno.readTextFile("./deno.json")
  .then(tx => JSON.parse(tx) as Record<string, Record<string, string>>).then(jdata => jdata.imports)
  .then(imports => {
    Deno.writeTextFile(_TEMP_MAP_NAME, JSON.stringify({imports}))
  })

const importMapURL = toFileUrl(resolve(_TEMP_MAP_NAME)).href

const TITLES = [
  "ページその1",
  "ページその2",
  "ページその3"
]


// ------- Set Web worker ----------
const myWorker =  new Worker(
      join(dirname(import.meta.url), "worker.tsx"),
      { type: "module" },
    )


// ------- bundle and create HTML ----------
esbuild.initialize({})
const script = await esbuild.build({
  plugins: [ ...denoPlugins({importMapURL}) ],
  entryPoints: { main: toFileUrl(resolve(CRIENT_PATH)).href },
  bundle: true,
  format: "esm",
  platform: "neutral",
  outfile: "bundled.js",
  jsx: "automatic",
  jsxImportSource: "preact",
}).then((_result: unknown) => Deno.readTextFile("./bundled.js"))
esbuild.stop()

await Deno.remove("./bundled.js")
await Deno.remove(_TEMP_MAP_NAME)

function View(){
  return(
    <html lang="en">
      <head>
        <meta charSet="utf-8"/>
        <script src="https://cdn.twind.style" crossOrigin="true"></script>
        <script dangerouslySetInnerHTML={{__html: twind_config_script}}></script>
        <script type="module" dangerouslySetInnerHTML={{__html: script}}></script>
      </head>
      <body>
        <App {...{titles:TITLES, max_len: TITLES.length}}/>
      </body>
    </html>
  )
}
const html = renderToString(View())


// ------- save HTML as a file ----------
const tempFilePath = await Deno.makeTempFile({suffix: ".html"})
await Deno.writeTextFile(tempFilePath, html)
Deno.env.delete("ToppageFilePath")
Deno.env.set("ToppageFilePath", tempFilePath)


// ------- navigate to webworker ----------
const webview = new Webview(true, {width: 600, height: 400, hint: SizeHint.NONE})
webview.title = "Deno App"
webview.navigate(`http://localhost:8080/`)

webview.run()
myWorker.terminate()
worker.tsx
worker.tsx
import { renderToString } from "https://esm.sh/preact-render-to-string@6.2.1?deps=preact@10.15.1"
import * as esbuild from "https://deno.land/x/esbuild@v0.19.2/mod.js"
import { denoPlugins } from "https://deno.land/x/esbuild_deno_loader@0.8.2/mod.ts"
import { toFileUrl, resolve } from "https://deno.land/std@0.200.0/path/mod.ts"
import { contentType } from "https://deno.land/std@0.200.0/media_types/mod.ts"
import { green, blue, red, cyan } from "https://deno.land/std@0.200.0/fmt/colors.ts"

import Page from "./Page.tsx"

import { defineConfig } from "https://esm.sh/@twind/core@1.1.3";
const TwindConfig =  {
  ...defineConfig({
    hash: false,
  })
}
const twind_config_script = `twind.install(${JSON.stringify(TwindConfig)})`


const CRIENT_PATH = "Client.tsx"
const _TEMP_MAP_NAME = "temp_map.json"

const HEADER_OPTION = {
  'Access-Control-Allow-Method':  'OPTIONS, GET, POST',
  'Access-Control-Allow-Headers': 'Content-Type, Origin',
  'Access-Control-Allow-Origin': 'null'
}


const PAGE_PATHS = [
  "./page_1.txt",
  "./page_2.txt",
  "./page_3.txt",
]
const MAX_LEN = 3


Deno.serve(
  {port: 8080},
  async (req) => {
  const url = new URL(req.url)

  // ---------- to root ---------------
  if (url.href == "http://localhost:8080/"){
    const html = await Deno.readTextFile(Deno.env.get("ToppageFilePath")!)
    const headers = new Headers({...HEADER_OPTION, "Content-Type":contentType("text/html")})
    console.log(`[${cyan("Worker")}] ${green(url.href)} ${blue("OK")}`)
    return new Response(html, {headers, status: 200})
  }


  // ---------- to subpages ---------------
  else if (new URLPattern({ pathname: '/page/:page_idx' }).exec(url)){
    const page_idx = Number(new URLPattern({ pathname: '/page/:page_idx' }).exec(url)!.pathname.groups["page_idx"]!)
    const path = PAGE_PATHS.at(page_idx-1)!
    const title = path.split("/").at(-1)!.split(".")[0]
    const text = await Deno.readTextFile(path)

    await Deno.readTextFile("./deno.json")
      .then(tx => JSON.parse(tx) as Record<string, Record<string, string>>).then(jdata => jdata.imports)
      .then(imports => {
        Deno.writeTextFile(_TEMP_MAP_NAME, JSON.stringify({imports}))
      })

    const importMapURL = toFileUrl(resolve(_TEMP_MAP_NAME)).href

    esbuild.initialize({})
    const script = await esbuild.build({
      plugins: [ ...denoPlugins({importMapURL}) ],
      entryPoints: { main: toFileUrl(resolve(CRIENT_PATH)).href },
      bundle: true,
      format: "esm",
      platform: "neutral",
      outfile: "bundled.js",
      jsx: "automatic",
      jsxImportSource: "preact",
    }).then((_result: unknown) => Deno.readTextFile("./bundled.js"))
    esbuild.stop()

    await Deno.remove("./bundled.js")
    await Deno.remove(_TEMP_MAP_NAME)

    function View(){
      return(
        <html lang="en">
          <head>
            <meta charSet="utf-8"/>
            <script src="https://cdn.twind.style" crossOrigin="true"></script>
            <script dangerouslySetInnerHTML={{__html: twind_config_script}}></script>
            <script type="module" dangerouslySetInnerHTML={{__html: script}}></script>
          </head>
          <body>
            <Page {...{info:{title, text, page_idx}, max_len:MAX_LEN}} />
          </body>
        </html>
      )
    }
    const headers = new Headers({...HEADER_OPTION, "Content-Type":contentType("html")})
    console.log(`[${cyan("Worker")}] ${green(url.href)} ${blue("OK")}`)
    return new Response(renderToString(View()), {headers, status: 200})
  }

  
  // ---------- 404 ---------------
  else {
    const headers = new Headers({...HEADER_OPTION, "Content-Type":contentType("text/plain")})
    console.log(`[${cyan("Worker")}] ${green(url.href)} ${red("404")}`)
    return new Response("", {headers, status: 404})
  }
})

その他のファイルは↓を参照
https://github.com/nikogoli/testing_Deno-Webview/tree/main/template/00_3_minimum_multi_pages