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

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

## Denoとは [Deno](https://ja.wikipedia.org/wiki/Deno) は [Node.js](https://ja.wikipedia.org/wiki/Node.js)の作者が新たに書き直した JavaScriptランタイムである。[V8エンジン](https://ja.wikipedia.org/wiki/V8_(JavaScript%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%B3))を使用した JavaScript実行環境である点では Node.jsと同じだが、主に [TypeScript](https://ja.wikipedia.org/wiki/TypeScript)を実行する用途を想定して開発されている。 Node.jsと Denoの大きな違いの一つに、Denoでは Node.jsでいうところの [npm](https://ja.wikipedia.org/wiki/Npm_(%E3%83%91%E3%83%83%E3%82%B1%E3%83%BC%E3%82%B8%E7%AE%A1%E7%90%86%E3%83%84%E3%83%BC%E3%83%AB))にあたるパッケージ管理ツールを使用してあらかじめ依存関係を解決する必要がないというものがある。Denoでは、ソースコード内の [import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import)文で直接 URLを指定して依存モジュールを参照することでランタイムが実行時にモジュールを自動でダウンロードする。 Node.jsの場合はプログラムを実行する前にプロジェクトで利用したいモジュールを ```package.json``` に記載して ```npm install``` を実行するという前準備が必要だったが、Denoではこれにあたる作業を飛ばしてプログラムを直接走らせられるということになる。 ## Angular とは [Angular](https://ja.wikipedia.org/wiki/Angular)はGoogleのコンポーネント指向 Webアプリケーションフレームワーク。前身となる [AngularJS](https://ja.wikipedia.org/wiki/AngularJS)(2009-2020)とは全く互換性が無く別物。同じ用途のフレームワークでは [React](https://ja.wikipedia.org/wiki/React)や [Vue.js](https://ja.wikipedia.org/wiki/Vue.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 のバンドル処理も行う。 ```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がするのはバンドル処理だけ)ことに注意。 ```ts // 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``` のみサーバー側プログラムでウォッチしており、変更を検出すると自動でバンドルしなおす。 ## 様子 ![ブラウザで表示している様子](greeting.png?classes=img-fluid,img-thumbnail)