Open7
Deno + Webview + Preact でデスクトップアプリ
0. 概要と目次
deno_webview を使ってデスクトップアプリを作るための fresh ライクなフレームワーク
このスクラップ自体は昔作っていたスクラップのアップグレード版というか整理版のようなもの
1-1. 仕組み
基本的な流れは以下の通り
- アプリケーションの JSXファイルを作る
- preact を使ってこの JSX を hydrate するファイルを作る
- このファイルを基準に必要なファイルを bundle し、1つのスクリプトにまとめる
- ルートとなる JSXをつくり、1 のJSXを
<body>
に、3 のスクリプトを<script>
にいれる - このJSX を renderToString し、 HTML を作る
- この HTML を webview で開く
具体的な形式としては、以下の3点セットが基本となる
-
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> ) }
-
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 )
-
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()
上記の3つのファイルと以下の deno.json
を同じフォルダに入れて
deno.json
{
"lock": false,
"tasks": {
"start": "deno run -A --unstable-ffi main.tsx"
}
}
deno task start
を実行すると (webviewが動くなら) 以下のようなウィンドウが立ち上がる
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()
上記の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が動くなら) 以下のようなウィンドウが立ち上がる
2. 複数のHTML間のルーティング
リンクを経由して複数のページ間を行き来する場合、WebWoker を使ってリクエストを捌く
そのため、以下のような流れになる
- アプリケーションのトップページの JSX を hydrate し、renderToString し、HTMLを作成
- この HTML を保存する
- WebWoker を起動し、deno でポート番号を指定して serve する
- WebView を起動し、3 で指定したポート番号の URL(
http://localhost:8088/
など)を開く - worker は
/
へのリクエストに対し、2 で保存した HTML を読み込んで中身を返す - 以降、woker ではリクエストに応じて、「 指定されたページの JSX を hydrate → renderToString → 作成されたHTML を返す」を繰り返す
挙動は↓のような感じ
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})
}
})
その他のファイルは↓を参照