Denoだけで走らせる Angular Webアプリケーション

ちょっとした入力にちょっとした結果を返すだけのWebアプリを作りたいだけなのにフレームワーク専用のコマンドラインツールで立派な初期ソースツリーの生成から始めるのが嫌な人用

2021年11月9日 嶋田大貴

Denoとは

DenoNode.jsの作者が新たに書き直した JavaScriptランタイムである。V8エンジンを使用した JavaScript実行環境である点では Node.jsと同じだが、主に TypeScriptを実行する用途を想定して開発されている。

Node.jsと Denoの大きな違いの一つに、Denoでは Node.jsでいうところの npmにあたるパッケージ管理ツールを使用してあらかじめ依存関係を解決する必要がないというものがある。Denoでは、ソースコード内の import文で直接 URLを指定して依存モジュールを参照することでランタイムが実行時にモジュールを自動でダウンロードする。

Node.jsの場合はプログラムを実行する前にプロジェクトで利用したいモジュールを package.json に記載して npm install を実行するという前準備が必要だったが、Denoではこれにあたる作業を飛ばしてプログラムを直接走らせられるということになる。

Angular とは

AngularはGoogleのコンポーネント指向 Webアプリケーションフレームワーク。前身となる AngularJS(2009-2020)とは全く互換性が無く別物。同じ用途のフレームワークでは ReactVue.jsの方が人気が高いが、当社ではAngularJS時代からのよしみで使っている。

Denoで Angular

Denoはまだ新しく、Node.jsと同じくらいまで認知度を得ているとは言い難いので Angularにせよ他のフレームワークにせよ Denoではなく Node.js上で利用することが揺るぎなき前提になっているが、Node.jsよりも Denoの方が新しい分シンプルなうえに事実上 TypeScriptが前提となっていてその裏にある JavaScriptSのことを考えずに利用できることから脳細胞の消費エネルギーを節約できる(ような気がした)ため、Denoだけで Angularを使った Webアプリケーションをサーバーとクライアントひとまとめで走らせられるか試してみた。

コード

サーバー (server.ts)

このプログラムは Denoを使って実行される。HTTPサーバとして動作するついでに、一緒に置いてある client.ts のバンドル処理も行う。

#!/usr/bin/env -S deno run --allow-net --allow-read --unstable --watch
import {Application,Router} from "https://deno.land/x/oak/mod.ts";
import {gzip} from "https://deno.land/x/compress/mod.ts";
import {parse} from "https://deno.land/std/flags/mod.ts";
import {decode} from "https://deno.land/std/encoding/base64.ts";

const args = parse(Deno.args);  // コマンドライン引数のパース

const HOSTNAME = args.h || undefined;   // bindするアドレス
const PORT = args.p || 8000;    // listenするポート
const CLIENT_TS = "./client.ts";    // クライアントアプリのソース

let app_js_gz:Uint8Array | undefined;   // 圧縮したバンドル
let app_js_map_gz:Uint8Array | undefined; // 圧縮したソースマップ
let last_bundled_time = 0;  // バンドル処理を最後に実行したタイムスタンプ

// client.tsをブラウザ用のjsにバンドルして圧縮しメモリにキャッシュする
// 要するにクライアントアプリケーションをビルドする関数
function bundle_client_app():void {
    const client_ts_time = Deno.lstatSync(CLIENT_TS).mtime?.getTime();
    if (!client_ts_time || client_ts_time <= last_bundled_time) return;

    last_bundled_time = client_ts_time;

    console.log("Bundling client app...");
    Deno.emit(CLIENT_TS, {
        bundle: "module",
        compilerOptions: {
            "emitDecoratorMetadata": true,
            lib: ["dom", "esnext"]
        }
    }).then(result=>{ // バンドルが完了したときの処理
        const textEncoder = new TextEncoder();
        // そのままだとばかでかくて不経済なのでgzipしてメモリに保持
        app_js_gz = gzip(textEncoder.encode(result.files["deno:///bundle.js"]));
        app_js_map_gz = gzip(textEncoder.encode(result.files["deno:///bundle.js.map"]));
        console.log("Bundling client app done.");
    }, reason=>{
        console.log("Bundle error! " + reason);
    });
}

bundle_client_app();

// Webサーバーフレームワークにはoakを使用
const app = new Application();
const router = new Router();

router.get("/", context=> { // トップのHTMLを出力
    context.response.type = "text/html";
    context.response.body = `<!DOCTYPE html>
<html><head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css" 
    rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" 
    crossorigin="anonymous">
<link href="./app.css" rel="stylesheet">
</head><body><div class="container"><app-root></app-root></div><script type="module" src="app.js"></script>
</body></html>`;
})
.get("/app.js", context => { // バンドルされたjsを出力
    // トップのHTMLから <script type="module" src="app.js"></script> でロードされる
    context.response.type = "application/javascript";
    if (!app_js_gz) {
        // まだバンドル処理が終わってない場合は503
        context.response.status = 503;
        context.response.body = "console.log('503 Service Temporarily Unavailable');";
        return;
    }
    //else
    context.response.headers.set("Content-Encoding", "gzip");
    context.response.body = app_js_gz;
})
.get("/app.js.map", context => { // ソースマップを出力
    context.response.type = "application/json";
    if (!app_js_map_gz) {
        context.response.status = 503;
        context.response.body = "[\"503 Service Temporarily Unavailable\"]";
        return;
    }
    //else
    context.response.headers.set("Content-Encoding", "gzip");
    context.response.body = app_js_map_gz;
})
.get("/app.css", context => { // CSSを出力(TODO: SCSSから自動生成したいね)
    context.response.type = "text/css";
    context.response.body = `.container {padding: 0 15px;}
`;
})
.get("/robots.txt", context => { // いちおうロボットお断りにしておく
    context.response.type = "text/plain";
    context.response.body = `User-agent: *
Disallow: /`
})
.get("/favicon.ico", context => { // 適当なfaviconを出力
    context.response.type = "image/vnd.microsoft.icon";
    context.response.headers.set("Content-Encoding", "gzip");
    context.response.body = decode(
        // gzip -c favicon.ico | base64 したもの
        `H4sICEvkiWEAA2Zhdmljb24uaWNvAO2Qz2vBYRjAP4bNftsP9nvDbDPbbLO9/4CcHeRfUPwHDkqO
iqRcHZzkIJKrf8CVpES5kKSUC6L2Nc4urj5P79vzeerpeZ8XZFKo1Ui3Co8StIBJOlIJHYv6KjQa
DXq9HrPZjMViwWazYbfbcTgcOJ1OXC4Xbrcbj8eD1+vF5/Ph9/sJBAIEg0FCoRDhcJhIJEI0GiUW
ixGPx0kkEiSTSVKpFOl0mkwmQzabJZfLkc/nKRQKFItFSqUS5XKZSqVCtVqlVqtRr9dpNBo0m01a
rRbtdptOp0O326XX69Hv9xkMBgyHQ0ajEePxmMlkwnQ6ZTabrdx3w4YNC8S6PMLW4Yu4PpDyy6O5
G57O5R/PGN/MSt3cP4V15/Znez7tfeFCcyIu1OJuXyxdeyoMsq+9+6Vbd2/Et+KB1383GM/kFqlH
ofpdzFMcm6REz9Xajxdi3f//A3XUHbd+BQAA`);
})
.post("/greeting", async context => { // 名前を教えると挨拶する REST API
    const body = await (await context.request.body({type:"json"})).value;
    context.response.type = "application/json";
    context.response.body = JSON.stringify({success:true, message:`Hello, ${body.name}!`});
});

app.use(router.routes());
app.use(router.allowedMethods());

console.log("Starting server" + (HOSTNAME? ' ' + HOSTNAME : "") + " at port " + PORT + ".");
app.listen({ hostname: HOSTNAME, port: PORT });

// クライアントアプリのソース変更をウォッチする
// サーバ側ソース(このファイル)は denoがウォッチしている
const watcher = Deno.watchFs(CLIENT_TS);
for await (const event of watcher) {
    if (event.kind == "modify") bundle_client_app();
}

クライアント (client.ts)

このプログラムは全ての依存モジュールと一緒にひとかたまりのjsファイルへとバンドルされ Webブラウザ上で実行される。Deno上で実行されるわけではない(Denoがするのはバンドル処理だけ)ことに注意。

// Angularを動かすのに必要なものを色々インポートする
import "https://cdn.skypack.dev/reflect-metadata";

import {BrowserModule} from "https://cdn.skypack.dev/@angular/platform-browser@11?dts";
import {platformBrowserDynamic} from "https://cdn.skypack.dev/@angular/platform-browser-dynamic@11?dts";
import {enableProdMode,NgModule,Component,Inject} from "https://cdn.skypack.dev/@angular/core@11?dts";
import {FormsModule} from "https://cdn.skypack.dev/@angular/forms@11?dts";
import {HttpClient,HttpClientModule,HttpErrorResponse } from 'https://cdn.skypack.dev/@angular/common@11/http?dts';
import "https://cdn.skypack.dev/zone.js";

// skypackからインポートする時はURLの末尾に ?dtsを付けると TypeScript型定義ファイル(*.d.ts)の
// ありかもdenoランタイムに教えてもらえるので型チェックが有効になる

// Webアプリケーションのメインコンポーネント
@Component({
    selector: 'app-root', // メインHTMLの<app-root></app-root>と書いてある部分にこのコンポーネントをはめ込む
    template: `<h1>greeting</h1><form (ngSubmit)="sayHello()">
    <div class="input-group mb-3">
        <input type="text" class="form-control" [(ngModel)]="name" placeholder="Your name" [ngModelOptions]="{standalone: true}">
        <button class="btn btn-primary" type="submit" [disabled]="!name">Say Hello</button>
    </div>
</form>
{{message}}`
})
class AppComponent {
    name = ""; // [(ngModel)]="name"として入力コントロールにバインドしている
    message:string | undefined; // テンプレートから{{message}}として参照している

    constructor(@Inject(HttpClient) private http:HttpClient) {}

    sayHello():void {
        // REST APIの呼び出し
        this.http.post<{success:boolean,message:string|undefined}>("./greeting", 
                {name:this.name}).subscribe((json) => {
            if (json.success) { // 呼び出し成功
                this.message = json.message;
            }
        }, (err:HttpErrorResponse)=> { // 呼び出し失敗
            console.log(err);
        });
    }
}

// Webアプリケーションのメインモジュール
@NgModule({
    declarations: [AppComponent],
    imports: [BrowserModule,FormsModule,HttpClientModule],
    providers: [],
    bootstrap: [AppComponent]
})
class AppModule {
}

// URLのホスト名部分がlocalhostでない場合は productionモードに設定する
if (location.hostname != "localhost" && !location.hostname.endsWith(".localhost")) {
    enableProdMode();
}

// アプリケーションの実行を開始
platformBrowserDynamic().bootstrapModule(AppModule);

使い方

動作条件は、denoにPATHが通っていること。Linuxでのみ確認している。

chmod +x server.ts して ./server.ts で実行すると 8000番ポートで Webサーバーが待ち受ける。その裏で client.ts がWebブラウザ向けにトランスパイル・バンドルされメモリ上にキャッシュされる。

ブラウザから localhost:8000 にアクセスして動作を確認する。

サーバー側ソースは Denoがウォッチしており、変更すると自動で再起動される。クライアント側ソースはさしあたり client.ts のみサーバー側プログラムでウォッチしており、変更を検出すると自動でバンドルしなおす。

様子

ブラウザで表示している様子

2021年11月9日 嶋田大貴

記事一覧へ戻る