0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

web開発のweb、AP、DBのサーバ機能を少し確認するデモ

Last updated at Posted at 2025-10-26

web開発勉強会 バックエンド編

導入説明

1. はじめに:フロントエンドの次に知るべき「サーバの世界」

フロントエンド編では、HTML・CSS・JavaScript
「見える部分」=クライアント側を体験しました。

ブラウザの中で見えるボタン・入力欄・アニメーションなどは
基本的にフロントエンドの役割です。


では、その裏側でどうやって「データ」がやりとりされているのか?
誰がどの画面に何を返しているのか?
それを学ぶのが バックエンド編 です。


2. 目標:Webアプリの「裏側」を理解する

この勉強会のゴールは、次の2つです。

目標 内容
① Webの裏側を可視化する ブラウザの裏で、Webサーバ→APサーバ→DBサーバが連携している流れを理解する
② 動くサンプルを自分の手で動かす PowerShell だけで動くミニサーバを立ち上げ、仕組みを体験する

「コードを読む」のではなく、各自動かして仕組みを観察する
コマンド1本でサーバを起動し、ログやブラウザで挙動を確かめることを目的にします。


3. 参加者の前提

  • Windows 環境(管理者権限不要)
  • 特別なインストールなし(PowerShellのみ使用)
  • フロントエンド編を見てあるとより効果的です

つまり、誰でも手元でサーバを体験できるようにしています。


4. 学習の流れ(全4ステップ)

バックエンド編は、段階的に仕組みを増やしていく構成になっています。

Step 構成 学ぶこと
Step1 Webサーバだけ 静的コンテンツを返す(HTMLをそのまま返す)
Step2 Web + AP フォーム入力を受け取り、動的な画面を返す
Step3 Web + AP + DB 簡易DBを追加し、データを保存・取得する
Step4 Web + API + DB JSONでやりとりするAPIサーバを作り、CORSを理解する

各ステップは基本的に前のコードを流用。
tomcatなどは利用せず、「車輪の再発明」を通じて、仕組みを手で体感します。


5. 実行イメージ

例えばstep3なら以下のようになります。

powershell -File .\backend_step3_dbsv.ps1   -Port 8082
powershell -File .\backend_step3_apsv.ps1   -Port 8081 -DbBase "http://localhost:8082"
powershell -File .\backend_step3_websv.ps1  -Port 8080 -ApBase "http://localhost:8081"
# → http://localhost:8080/ にアクセス

ブラウザで上記URLを開くだけで、
あなたのパソコンの中で「小さなweb、インターネット的なもの」が動きます。


6. フロントエンド編とのつながり

フロントエンド編で学んだのは:

「URLアクセス/何かボタンを押したらデータを取りに行く/送る」

バックエンド編では:

「その送受信の先で、どんなサーバが何を処理/応答しているのか?」

を実際に確かめます。

画面の裏側にある「見えない世界(サーバ)」を、
ソース、ログ、開発者ツールといった手段でのぞいていきます。


7. 学ぶキーワード

  • HTTP(リクエスト/レスポンス/ステータスコード)
  • ルーティング(URLと処理の対応付け)
  • テンプレートと動的生成
  • データ保存(DB)
  • バリデーション(入力チェック)
  • JSON と API
  • CORS(異なるオリジン間通信)
  • 認証(発展テーマの例)

8. この後の流れ

  1. Stepごとのサーバを順に立ち上げ、挙動を確認
  2. 各Stepで出てくるログ/開発者ツールを読み解く
  3. 最終的にフロントエンドとバックエンドの構成、役割を理解

ゴールは「Webの裏側で何が起きているか」を自分の言葉で説明できるようになること。


9. 次の発展(紹介のみ)

  • APIに「認証(トークンやCookie)」を加えるとどうなるか?
  • PowerShellで再発明した部分を、実際のWebフレームワーク(Flask, Express, Springなど)で置き換えるとどうなるか?
    #再発明したスクリプトは、インフラロジックと業務ロジックが混在している点に着目。

ここまでのまとめ

フロントエンド:見える世界を作る。
バックエンド:見えない世界を動かす。

この2つが合わさって、初めて「Webアプリ」と呼べます。

バックエンド編では、仕組みをシンプルに再現しながら、
「動くWebアプリの裏側」 を体感していきましょう。


勉強会ファイル配置

workshop_backend
├─step1_web
│  │  backend_step1_websv.ps1       ←フロントエンド編の場所にps1を配置するイメージ
│  │  index.html
│  │
│  ├─step1_html_only
│  │      index.html
│  │
│  ├─step2_css_switch
│  │      index.html
│  │      modern.css
│  │      retro.css
│  │      tmp.css
│  │
│  ├─step3_dynamic
│  │      index.html
│  │      script.js
│  │      style.css
│  │
│  └─step4_external
│          index.html
│          script.js
│          style.css
│
├─step2_ap
│  │  backend_step2_apsv.ps1
│  │  backend_step2_websv.ps1
│  │
│  └─www
│          index.html
│
├─step3_ap_db
│  │  backend_step3_apsv.ps1
│  │  backend_step3_dbsv.ps1
│  │  backend_step3_websv.ps1
│  │
│  └─www
│          index.html
│
└─step4_api_db
    │  backend_step4_apisv.ps1
    │  backend_step4_dbsv.ps1
    │  backend_step4_websv.ps1
    │
    └─www
            index.html

各stepでのポイント:以下をログ、開発者ツールから確認しましょう。

  • step1では、純粋にファイルを配信していること
  • step2では、動的に画面が生成されること
  • step3では、裏で永続層へのアクセスが行われていること
  • step4では、画面が直接APIを利用する場合の挙動
  • おまけとして、ログを選択状態として、同期処理の必要性を確認すること

step1 webサーバの動作を確認してみよう

backend_step1_websv.ps1
param(
  [int]$Port = 8080,                  # 希望ポート(競合時は自動で別ポート)
  [switch]$Open,                      # 起動後にブラウザ自動オープン
  [string]$LogPath = ".\access.log",  # アクセスログ(空なら無効)
  [string]$ServerLogPath = ".\server.log", # サーバ動作ログ
  [switch]$Plain                      # 絵文字を使わず ASCII 表示
)

# ---- 記号(絵文字/ASCII) ----
if ($Plain) {
  $SYM_OK   = "[OK]"
  $SYM_STOP = "[STOP]"
  $SYM_END  = "[END]"
} else {
  $SYM_OK   = "✅"
  $SYM_STOP = "🛑"
  $SYM_END  = "👋"
}

# ルート=このスクリプトのある場所(カレントに依存しない)
$Root = Split-Path -Parent $MyInvocation.MyCommand.Definition

# 停止用のワンタイムトークン(HTTPで安全に停止)
$StopToken = [Guid]::NewGuid().ToString("N")

# ---- ログ関数(時刻付き)----
function Write-AccessLog($msg) {
  $line = ("{0} {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"), $msg)
  Write-Host $line
  if ($LogPath -and $LogPath.Trim()) {
    try { Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8 } catch {}
  }
}
function Write-ServerLog($msg) {
  $line = ("{0} {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"), $msg)
  Write-Host $line
  if ($ServerLogPath -and $ServerLogPath.Trim()) {
    try { Add-Content -LiteralPath $ServerLogPath -Value $line -Encoding UTF8 } catch {}
  }
}

# ---- Content-Type ----
function Get-ContentType([string]$path) {
  switch -regex ($path.ToLowerInvariant()) {
    '\.html?$' { 'text/html; charset=utf-8' ; break }
    '\.css$'   { 'text/css; charset=utf-8' ; break }
    '\.js$'    { 'application/javascript; charset=utf-8' ; break }
    '\.json$'  { 'application/json; charset=utf-8' ; break }
    '\.png$'   { 'image/png' ; break }
    '\.jpe?g$' { 'image/jpeg' ; break }
    '\.gif$'   { 'image/gif' ; break }
    '\.svg$'   { 'image/svg+xml' ; break }
    '\.ico$'   { 'image/x-icon' ; break }
    default    { 'application/octet-stream' }
  }
}

# ---- クエリ文字列パース(PS5.1互換・System.Webなし)----
function Parse-Query([string]$query) {
  $result = @{}
  if (-not $query) { return $result }
  $q = $query.TrimStart('?')
  if (-not $q) { return $result }
  foreach ($pair in $q -split '&') {
    if ($pair -eq '') { continue }
    $kv = $pair -split '=', 2
    $k = [System.Uri]::UnescapeDataString($kv[0])
    $v = ''
    if ($kv.Count -gt 1) {
      $v = [System.Uri]::UnescapeDataString($kv[1])
    }
    $result[$k] = $v
  }
  return $result
}

# ---- ルート外へ出ないための正規化 ----
function Resolve-UnderRoot([string]$root, [string]$requested) {
  $unsafe = [System.Uri]::UnescapeDataString($requested).TrimStart('/')
  if ([string]::IsNullOrWhiteSpace($unsafe)) { $unsafe = 'index.html' }
  $unsafe = $unsafe -replace '/', '\'
  $combined = Join-Path -Path $root -ChildPath $unsafe
  try {
    $full = [System.IO.Path]::GetFullPath($combined)
    $rootFull = [System.IO.Path]::GetFullPath($root)
    if ($full.StartsWith($rootFull, [StringComparison]::OrdinalIgnoreCase)) { return $full }
  } catch {}
  return $null
}

# ---- ポート確保(競合時は次候補へ)----
function Start-Listener([int]$startPort) {
  $candidates = @($startPort, 8787, 5173, 8888) + (20000..20100 | Get-Random -Count 5)
  foreach($p in $candidates){
    try {
      $l = [System.Net.HttpListener]::new()
      $l.Prefixes.Add("http://localhost:$p/")
      $l.Start()
      return @($l, $p)
    } catch [System.Net.HttpListenerException] {
      continue  # 競合などは次へ
    } catch {
      continue
    }
  }
  throw "利用可能なポートが見つかりませんでした。"
}

$listener, $actualPort = Start-Listener -startPort $Port

Write-ServerLog "$SYM_OK 起動: http://localhost:$actualPort/"
Write-ServerLog "📁 ルート: $Root"
Write-ServerLog "$SYM_STOP 停止URL: http://localhost:$actualPort/__stop?token=$StopToken"
if ($Open) { Start-Process "http://localhost:$actualPort/" }

try {
  while ($true) {
    $context = $null
    try {
      $context = $listener.GetContext()
    } catch [System.Net.HttpListenerException] {
      break  # Stop() 済みなど
    } catch {
      Write-ServerLog "ERROR accept: $($_.Exception.Message)"
      continue
    }

    $req = $context.Request
    $res = $context.Response
    $path = $req.Url.AbsolutePath
    $status = 200

    # ---- 停止エンドポイント:/__stop?token=... ----
    if ($path -eq "/__stop") {
      $qp = Parse-Query $req.Url.Query
      $token = $qp["token"]
      if ($token -eq $StopToken) {
        $msg = [System.Text.Encoding]::UTF8.GetBytes("stopping")
        $res.ContentType = "text/plain; charset=utf-8"
        $res.OutputStream.Write($msg, 0, $msg.Length)
        try { $res.OutputStream.Close() } catch {}
        Write-ServerLog "STOP requested via HTTP."
        break
      } else {
        $status = 403
        $res.StatusCode = 403
        $msg = [System.Text.Encoding]::UTF8.GetBytes("403 Forbidden")
        $res.ContentType = "text/plain; charset=utf-8"
        $res.OutputStream.Write($msg, 0, $msg.Length)
        try { $res.OutputStream.Close() } catch {}
        Write-AccessLog ("{0} {1} -> {2}" -f $req.HttpMethod, $path, $status)
        continue
      }
    }

    # ---- 静的ファイル配信 ----
    $filePath = Resolve-UnderRoot -root $Root -requested $path
    if ($filePath -and (Test-Path -LiteralPath $filePath -PathType Leaf)) {
      try {
        $bytes = [System.IO.File]::ReadAllBytes($filePath)
        $res.ContentType = Get-ContentType $filePath
        $res.ContentLength64 = $bytes.LongLength
        $res.OutputStream.Write($bytes, 0, $bytes.Length)
      } catch {
        $status = 500
        $res.StatusCode = 500
        $msg = [System.Text.Encoding]::UTF8.GetBytes("500 Internal Server Error")
        $res.ContentType = "text/plain; charset=utf-8"
        $res.OutputStream.Write($msg, 0, $msg.Length)
        Write-ServerLog ("ERROR send: $($_.Exception.Message)")
      }
    } else {
      $status = 404
      $res.StatusCode = 404
      $msg = [System.Text.Encoding]::UTF8.GetBytes("404 Not Found")
      $res.ContentType = "text/plain; charset=utf-8"
      $res.OutputStream.Write($msg, 0, $msg.Length)
    }

    Write-AccessLog ("{0} {1} -> {2}" -f $req.HttpMethod, $path, $status)
    try { $res.OutputStream.Close() } catch {}
  }
}
finally {
  try { $listener.Stop() } catch {}
  Write-ServerLog "$SYM_END 終了しました。"
}

step1 起動方法

$ powershell -ExecutionPolicy Bypass -File .\backend_step1_websv.ps1 -Port 8080
2025-10-26 12:12:49.770 ✅ 起動: http://localhost:8080/
2025-10-26 12:12:49.805 📁 ルート: D:\var\work\20251005_web_study\step\local\backend\step1_web
2025-10-26 12:12:49.820 🛑 停止URL: http://localhost:8080/__stop?token=24962b6c005d44b5ba03dd1946731da9
2025-10-26 12:13:15.768 GET / -> 200
2025-10-26 12:13:21.072 GET /step1_html_only/index.html -> 200
2025-10-26 12:13:23.869 GET /step4_external/index.html -> 200
2025-10-26 12:13:23.944 GET /step4_external/style.css -> 200
2025-10-26 12:13:24.041 GET /step4_external/script.js -> 200
2025-10-26 12:13:26.718 GET /step2_css_switch/index.html -> 200
2025-10-26 12:13:26.791 GET /step2_css_switch/modern.css -> 200
2025-10-26 12:13:29.551 GET /step3_dynamic/index.html -> 200
2025-10-26 12:13:29.619 GET /step3_dynamic/style.css -> 200
2025-10-26 12:13:29.661 GET /step3_dynamic/script.js -> 200
2025-10-26 12:13:59.641 STOP requested via HTTP.
2025-10-26 12:13:59.650 👋 終了しました。
$

step2 APサーバの処理を確認してみよう

backend_step2_websv.ps1
param(
  [int]    $Port = 8787,
  [string] $Root = ".",
  [switch] $Open, # 起動時ブラウザオープン(任意)
  [string] $AppBase = "http://localhost:8081", # APサーバの入口
  [string[]] $AppPaths = @("/app/")                 # プロキシ対象パスの先頭一致
)

#=== ログ設定 ================================================================
$LogDir = (Resolve-Path .).Path
$AccessLog = Join-Path $LogDir "web_access.log"
$ServerLog = Join-Path $LogDir "web_server.log"
function Write-AccessLog([string]$line) {
  $ts = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss.fff")
  Add-Content -Path $AccessLog -Value "$ts $line" -Encoding UTF8
}
function Write-ServerLog([string]$line) {
  $ts = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss.fff")
  Add-Content -Path $ServerLog -Value "$ts $line" -Encoding UTF8
  Write-Host "$ts $line"
}

#=== Content-Type 判定 ======================================================
function Get-ContentType([string]$p) {
  $l = $p.ToLower()
  switch -regex ($l) {
    '\.html?$' { return 'text/html; charset=utf-8' }
    '\.css$' { return 'text/css; charset=utf-8' }
    '\.js$' { return 'application/javascript; charset=utf-8' }
    '\.json$' { return 'application/json; charset=utf-8' }
    '\.svg$' { return 'image/svg+xml' }
    '\.png$' { return 'image/png' }
    '\.jpe?g$' { return 'image/jpeg' }
    '\.gif$' { return 'image/gif' }
    '\.ico$' { return 'image/x-icon' }
    default { return 'application/octet-stream' }
  }
}

#=== クエリパース(?a=1&b=2) ==============================================
function Parse-Query([string]$q) {
  $map = @{}
  if ([string]::IsNullOrWhiteSpace($q)) { return $map }
  $trim = $q.TrimStart('?')
  $pairs = $trim.Split('&', [System.StringSplitOptions]::RemoveEmptyEntries)
  foreach ($p in $pairs) {
    $kv = $p -split '=', 2
    $k = $kv[0]
    $v = ""
    if ($kv.Count -gt 1) { $v = [Uri]::UnescapeDataString($kv[1]) }
    $map[$k] = $v
  }
  return $map
}

#=== リバースプロキシ(AP へ転送) =========================================
function Proxy-ToApp($context, [string]$appBase) {
  $req = $context.Request
  $res = $context.Response
  $target = ($appBase.TrimEnd('/')) + $req.Url.PathAndQuery

  $outReq = [System.Net.HttpWebRequest]::Create($target)
  $outReq.Method = $req.HttpMethod

  # 転送禁止(ホップ間)ヘッダー一覧
  $hopByHop = @(
    'Connection', 'Proxy-Connection', 'Keep-Alive', 'Transfer-Encoding', 'TE',
    'Trailer', 'Upgrade', 'Host', 'Content-Length', 'Expect', 'Date', 'Via',
    'If-Modified-Since', 'Range' # ここは用途次第で許可しても良い
  )

  # メソッド/基本情報
  $outReq.Method = $req.HttpMethod
  $outReq.AllowAutoRedirect = $false
  try { $outReq.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate } catch {}

  # ContentType は専用プロパティで
  if ($req.ContentType) { $outReq.ContentType = $req.ContentType }

  # 一般ヘッダーをコピー(ホップ間は除外)
  foreach ($k in $req.Headers.AllKeys) {
    if ($hopByHop -contains $k) { continue }
    try {
      $outReq.Headers[$k] = $req.Headers[$k]
    }
    catch {
      # 一部は制限されている可能性があるので握りつぶし
    }
  }

  # Connection: close / keep-alive を反映(必要なら)
  if ($req.Headers['Connection']) {
    $outReq.KeepAlive = ($req.Headers['Connection'] -notmatch 'close')
  }

  # ボディをそのまま転送(POST/PUTなど)
  if ($req.HasEntityBody) {
    $ms = New-Object System.IO.MemoryStream
    $req.InputStream.CopyTo($ms)
    $bytes = $ms.ToArray()
    $outReq.ContentLength = $bytes.Length
    $out = $outReq.GetRequestStream()
    $out.Write($bytes, 0, $bytes.Length)
    $out.Close()
    $ms.Dispose()
  }

  # --- AP からのレスポンスを受けてクライアントへ返す ---

  try {
    $apResp = [System.Net.HttpWebResponse]$outReq.GetResponse()

    # ステータスコード/Reason
    $res.StatusCode = [int]$apResp.StatusCode
    $res.StatusDescription = $apResp.StatusDescription

    # レスポンスヘッダーをコピー(ホップ間は除外)
    foreach ($k in $apResp.Headers.AllKeys) {
      if ($hopByHop -contains $k) { continue }
      try {
        $res.Headers[$k] = $apResp.Headers[$k]
      }
      catch {}
    }

    # ContentType は専用プロパティで
    if ($apResp.ContentType) { $res.ContentType = $apResp.ContentType }

    # 本文をストリーム転送
    $apStream = $apResp.GetResponseStream()
    $apStream.CopyTo($res.OutputStream)
    $apStream.Close()
    $apResp.Close()
  }
  catch [System.Net.WebException] {
    # AP 側で 4xx/5xx の場合もここに来るので、可能なら中身を返す
    $wex = $_.Exception
    if ($wex.Response -is [System.Net.HttpWebResponse]) {
      $apResp = [System.Net.HttpWebResponse]$wex.Response
      $res.StatusCode = [int]$apResp.StatusCode
      $res.StatusDescription = $apResp.StatusDescription

      foreach ($k in $apResp.Headers.AllKeys) {
        if ($hopByHop -contains $k) { continue }
        try { $res.Headers[$k] = $apResp.Headers[$k] } catch {}
      }
      if ($apResp.ContentType) { $res.ContentType = $apResp.ContentType }

      $apStream = $apResp.GetResponseStream()
      if ($apStream) {
        $apStream.CopyTo($res.OutputStream)
        $apStream.Close()
      }
      $apResp.Close()
    }
    else {
      # ネットワーク系の失敗
      $res.StatusCode = 502
      $res.ContentType = 'text/plain; charset=utf-8'
      $msg = "Bad Gateway (proxy to App failed): {0}" -f $wex.Message
      $bytes = [System.Text.Encoding]::UTF8.GetBytes($msg)
      $res.OutputStream.Write($bytes, 0, $bytes.Length)
    }
  }
}

#=== 停止URLの準備 ==========================================================
$StopToken = [Guid]::NewGuid().ToString("N")
$StopPath = "/__stop"
$StopUrl = "http://localhost:$Port/__stop?token=$StopToken"

#=== HttpListener 準備(localhost のみ) ====================================
$listener = [System.Net.HttpListener]::new()
$listener.Prefixes.Add("http://localhost:$Port/")
try { $listener.Start() } catch {
  Write-ServerLog "FAILED: port $Port is busy or permission denied."
  throw
}

Write-ServerLog "START: http://localhost:$Port/"
Write-ServerLog ("ROOT : " + (Resolve-Path $Root).Path)
Write-ServerLog ("STOP : $StopUrl")
if ($Open) { Start-Process "http://localhost:$Port/" | Out-Null }

#=== メインループ ===========================================================
try {
  while ($true) {
    $ctx = $listener.GetContext()
    $req = $ctx.Request
    $res = $ctx.Response
    $path = $req.Url.AbsolutePath
    $q = $req.Url.Query

    # 停止URL
    if ($path -eq $StopPath) {
      $qp = Parse-Query $q
      if ($qp["token"] -eq $StopToken) {
        Write-AccessLog "STOP requested via HTTP."
        $b = [Text.Encoding]::UTF8.GetBytes("Web Server stopping...")
        $res.OutputStream.Write($b, 0, $b.Length); $res.OutputStream.Close()
        break
      }
      else {
        $res.StatusCode = 403
        $b = [Text.Encoding]::UTF8.GetBytes("403 Forbidden")
        $res.OutputStream.Write($b, 0, $b.Length); $res.OutputStream.Close()
        Write-AccessLog "STOP invalid token."
        continue
      }
    }

    # プロキシ対象: /app/* など
    $isProxied = $false
    foreach ($prefix in $AppPaths) {
      if ($path.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) {
        Proxy-ToApp $ctx $AppBase
        $isProxied = $true
        break
      }
    }
    if ($isProxied) { continue }

    # 静的配信(ディレクトリトラバーサル対策)
    $rel = $path.TrimStart('/')
    if ([string]::IsNullOrWhiteSpace($rel)) { $rel = "index.html" }
    $full = Join-Path $Root $rel
    $rootFull = (Resolve-Path $Root).Path
    $okPath = $false
    try {
      $fullReal = (Resolve-Path -LiteralPath $full -ErrorAction Stop).Path
      if ($fullReal.StartsWith($rootFull, [StringComparison]::OrdinalIgnoreCase)) { $okPath = $true }
    }
    catch { $okPath = $false }

    if ($okPath -and (Test-Path -LiteralPath $full -PathType Leaf)) {
      $buf = [IO.File]::ReadAllBytes($full)
      $res.ContentType = Get-ContentType $full
      $res.ContentLength64 = $buf.LongLength
      $res.OutputStream.Write($buf, 0, $buf.Length)
      $res.OutputStream.Close()
      Write-AccessLog ("{0} {1} -> 200 ({2} bytes)" -f $req.HttpMethod, $path, $buf.Length)
    }
    else {
      $res.StatusCode = 404
      $msg = [Text.Encoding]::UTF8.GetBytes("404 Not Found")
      $res.OutputStream.Write($msg, 0, $msg.Length)
      $res.OutputStream.Close()
      Write-AccessLog ("{0} {1} -> 404" -f $req.HttpMethod, $path)
    }
  }
}
finally {
  $listener.Stop()
  Write-ServerLog "Web Server STOPPED."
}
backend_step2_apsv.ps1
# backend_step2_apsv.ps1
# - ポート: http://localhost:8081/
# - エンドポイント:
#     GET  /health                -> {"status":"ok"}
#     GET  /__stop?token=...      -> サーバ停止
#     GET/POST /app/greet         -> 動的HTML
# - ログ: ap_server.log / ap_access.log(コンソールにも出力)

param(
  [int]    $Port = 8081,
  [switch] $Open
)

$ErrorActionPreference = 'Stop'
$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'

#=== パス/ログ ===#
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $ScriptDir
$ServerLog = Join-Path $ScriptDir 'ap_server.log'
$AccessLog = Join-Path $ScriptDir 'ap_access.log'
$BaseUrl = "http://localhost:$Port/"
$StopToken = -join ((48..57 + 97..102) | Get-Random -Count 32 | ForEach-Object { [char]$_ })

function Write-ServerLog([string]$msg) {
  $line = ("{0} [AP SRV] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"), $msg)
  Add-Content -Path $ServerLog -Value $line -Encoding UTF8
  Write-Host $line
}
function Write-AccessLog([string]$msg) {
  $line = ("{0} [AP ACC] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"), $msg)
  Add-Content -Path $AccessLog -Value $line -Encoding UTF8
  Write-Host $line
}

#=== ユーティリティ ===#
function HtmlEscape([string]$s) {
  if ($null -eq $s) { return "" }
  $s = $s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace('"', "&quot;").Replace("'", "&#39;")
  return $s
}
function UrlDecode([string]$s) {
  try { return [System.Web.HttpUtility]::UrlDecode($s) }
  catch { return [System.Uri]::UnescapeDataString($s) }
}
function Parse-Query([Uri]$uri) {
  $map = @{}
  $q = $uri.Query
  if ([string]::IsNullOrEmpty($q)) { return $map }
  foreach ($pair in $q.TrimStart('?').Split('&', [System.StringSplitOptions]::RemoveEmptyEntries)) {
    $kv = $pair -split '=', 2
    $k = UrlDecode $kv[0]
    $v = ''
    if ($kv.Count -gt 1) { $v = UrlDecode $kv[1] }
    $map[$k] = $v
  }
  return $map
}
function Read-Body($ctx) {
  $sr = New-Object System.IO.StreamReader($ctx.Request.InputStream, $ctx.Request.ContentEncoding)
  $body = $sr.ReadToEnd()
  $sr.Close()
  return $body
}
function Parse-Form([string]$body) {
  $map = @{}
  if ([string]::IsNullOrWhiteSpace($body)) { return $map }
  foreach ($kv in $body -split "&") {
    if ($kv -eq "") { continue }
    $parts = $kv -split "=", 2
    $k = UrlDecode $parts[0]
    $v = ""
    if ($parts.Count -gt 1) { $v = UrlDecode $parts[1] }
    $map[$k] = $v
  }
  return $map
}
function Send-Text($ctx, [string]$text, [int]$code = 200, [string]$contentType = "text/plain; charset=utf-8") {
  $bytes = [Text.Encoding]::UTF8.GetBytes($text)
  $ctx.Response.StatusCode = $code
  $ctx.Response.ContentType = $contentType
  $ctx.Response.ContentLength64 = $bytes.Length
  $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
  $ctx.Response.OutputStream.Close()
}
function Send-Json($ctx, $obj, [int]$code = 200) {
  $json = $obj | ConvertTo-Json -Depth 6
  Send-Text $ctx $json $code "application/json; charset=utf-8"
}
function Send-Html($ctx, [string]$html, [int]$code = 200) {
  $bytes = [Text.Encoding]::UTF8.GetBytes($html)
  $ctx.Response.StatusCode = $code
  $ctx.Response.ContentType = "text/html; charset=utf-8"
  $ctx.Response.ContentLength64 = $bytes.Length
  $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
  $ctx.Response.OutputStream.Close()
}
function Send-Redirect($ctx, [string]$location) {
  $ctx.Response.StatusCode = 302
  $ctx.Response.RedirectLocation = $location
  $ctx.Response.OutputStream.Close()
}

# ---------------- アプリ(/app/greet) ----------------

# 時間帯に応じたテーマ&挨拶
function Select-ThemeAndGreeting([int]$h) {
  # デフォルト(夜)
  $timerange = "17 - 4"; $greeting = "こんばんは"; $emoji = "🌙"
  $theme = @{ bg = "#0a0f1a"; panel = "#0d1320"; accent = "#60a5fa"; grad = "linear-gradient(135deg,#1e293b,#111827)" }

  if ($h -ge 5 -and $h -le 10) {
    $timerange = "5 - 10"; $greeting = "おはようございます"; $emoji = "🌅"
    $theme = @{ bg = "#0c1626"; panel = "#0f1e33"; accent = "#f59e0b"; grad = "linear-gradient(135deg,#fde68a,#60a5fa)" }
  }
  elseif ($h -ge 11 -and $h -le 16) {
    $timerange = "11 - 16"; $greeting = "こんにちは"; $emoji = "🌞"
    $theme = @{ bg = "#0b1222"; panel = "#0f172a"; accent = "#22c55e"; grad = "linear-gradient(135deg,#86efac,#60a5fa)" }
  }
  return @{ timerange = $timerange ; greet = $greeting; emoji = $emoji; theme = $theme }
}
function Handle-AppGreet($ctx) {
  $req = $ctx.Request
  $uri = $req.Url
  $qs = Parse-Query $uri
  # フォーム(x-www-form-urlencoded)
  $form = @{}
  if ($req.HttpMethod -in @('POST', 'PUT')) {
    if ($req.ContentType -and $req.ContentType -like 'application/x-www-form-urlencoded*') {
      $form = Parse-Form (Read-Body $ctx)
    }
  }
  $name = $form['name']; if (-not $name) { $name = $qs['name'] }
  $hh = $form['hh']; if (-not $hh) { $hh = $qs['hh'] }
  $mm = $form['mm']; if (-not $mm) { $mm = $qs['mm'] }

  # デフォルト補完(未指定なら現在時刻)
  if (-not $hh) { $hh = (Get-Date).Hour.ToString('00') }
  if (-not $mm) { $mm = (Get-Date).Minute.ToString('00') }
  if (-not $name) { $name = 'guest' }

  # 数値バリデーション(範囲チェック)
  $h = 0; [void][int]::TryParse($hh, [ref]$h)
  $m = 0; [void][int]::TryParse($mm, [ref]$m)
  if ($h -lt 0 -or $h -gt 23 -or $m -lt 0 -or $m -gt 59) {
    return @{ code = 400; type = 'text/plain; charset=utf-8'; body = "400 Bad Request: hh/mm out of range" }
  }

  $sel = Select-ThemeAndGreeting $h
  $timerange = $sel["timerange"]; $greet = $sel["greet"]; $emoji = $sel["emoji"]; $t = $sel["theme"]
  $nowStr = ("{0:D2}:{1:D2}" -f $h, $m)
  $safe = HtmlEscape $name

  # HTML(時間帯テーマ+挨拶)
  $html = @"
<!doctype html>
<html lang="ja">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>$emoji $greet$safe さん</title>
<style>
  :root{ --bg:$($t.bg); --panel:$($t.panel); --text:#e5e7eb; --muted:#94a3b8; --accent:$($t.accent); --radius:16px; }
  *{ box-sizing:border-box }
  body{
    margin:0; background:
      radial-gradient(1200px 800px at 15% -10%, #1e293b 0, var(--bg) 40%, #050a16 100%),
      $($t.grad);
    color:var(--text); font:16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", Meiryo, sans-serif;
    min-height:100vh; display:grid; place-items:center;
  }
  .card{ width:min(720px,92vw); background:var(--panel); border:1px solid #1f2937; border-radius:var(--radius); padding:24px; box-shadow:0 10px 28px rgba(0,0,0,.35) }
  h1{ margin:.25rem 0 1rem; font-size:22px }
  p{ margin:.5rem 0 }
  .muted{ color:var(--muted); margin:.25rem 0 1rem }
  .badge{ display:inline-block; padding:4px 10px; border-radius:999px; background:
          linear-gradient(180deg, rgba(255,255,255,.06), rgba(0,0,0,.1)); border:1px solid #1f2937; font-size:12px }
  a.btn{
    display:inline-block; margin-top:.5rem; text-decoration:none; color:#0b1222; background:var(--accent);
    padding:.6rem 1rem; border-radius:10px; font-weight:700
  }
  a.btn:hover{ filter:brightness(1.05) }
  code{ background:#0b0e14; border:1px solid #1f2937; padding:.1em .4em; border-radius:8px }
</style>
<main class="card">
  <div class="badge">$emoji $timerange</div>
  <h1>$greet$safe さん</h1>
  <p class="muted">入力された時刻: <code>$nowStr</code></p>
  <p>このページは <strong>APサーバ (:8081)</strong> が、送信されたパラメータ <code>?name=&amp;hh=&amp;mm=</code> を基に、時間帯に応じたテーマで <strong>動的に生成</strong>しました。</p>
  <p><a class="btn" href="/">入力に戻る</a></p>
</main>
"@

  return @{ code = 200; type = 'text/html; charset=utf-8'; body = $html }
}

#=== HttpListener 起動 ===#
$listener = New-Object System.Net.HttpListener
$prefix = "http://localhost:$Port/"
$listener.Prefixes.Add($prefix)

try { $listener.Start() } catch {
  Write-ServerLog "ERROR: Port $Port might be in use or permission denied. $($_.Exception.Message)"
  throw
}

Write-ServerLog "START: $prefix"
Write-ServerLog "STOP : ${prefix}__stop?token=$StopToken"
if ($Open) { Start-Process $BaseUrl | Out-Null }

#=== メインループ ===#
$running = $true
try {
  while ($running -and $listener.IsListening) {
    try { $ctx = $listener.GetContext() } catch { break }
    $sw = [Diagnostics.Stopwatch]::StartNew()
    $req = $ctx.Request
    $res = $ctx.Response
    $method = $req.HttpMethod
    $rawUrl = $req.RawUrl
    $path = $req.Url.AbsolutePath
    $status = 200

    try {
      if ($method -eq "GET" -and $path -eq "/health") {
        Send-Json $ctx @{ status = "ok" } 200
      }
      elseif ($method -eq "GET" -and $path -eq "/__stop") {
        $qs = Parse-Query $req.Url
        if ($qs["token"] -eq $StopToken) {
          Send-Text $ctx "AP Server stopping..." 200
          $running = $false
        }
        else {
          Send-Text $ctx "Invalid token" 403
          $status = 403
        }
      }
      elseif ($path -ieq "/app/greet" -and ($method -in @("GET", "POST"))) {
        $r = Handle-AppGreet $ctx
        $code = [int]$r.code
        $ctype = [string]$r.type
        $body = [string]$r.body
        Send-Text $ctx $body $code $ctype
        $status = $code
      }
      else {
        Send-Text $ctx "Not Found" 404
        $status = 404
      }
    }
    catch {
      $status = 500
      Write-ServerLog ("ERROR: {0}" -f $_.Exception.Message)
      try { Send-Text $ctx "Internal Server Error" 500 } catch {}
    }
    finally {
      $sw.Stop()
      Write-AccessLog ("{0} {1} {2} {3}ms -> {4}" -f $req.RemoteEndPoint, $method, $rawUrl, $sw.ElapsedMilliseconds, $status)
    }
  }
}
finally {
  if ($listener.IsListening) { $listener.Stop(); $listener.Close() }
  Write-ServerLog "AP Server STOPPED."
}


www/index.html
<!doctype html>
<html lang="ja">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <title>はじめまして</title>
    <style>
        :root {
            --bg: #0e1220;
            --panel: #12182a;
            --fg: #e7ebf3;
            --muted: #a3acc2;
            --border: #1e2740;
            --accent: #7aa2ff;
        }

        * {
            box-sizing: border-box
        }

        body {
            margin: 0;
            background: radial-gradient(1200px 800px at 15% -10%, #1e293b 0, var(--bg) 40%, #060a16 100%);
            color: var(--fg);
            font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", Meiryo, sans-serif;
            min-height: 100vh;
            display: grid;
            place-items: center;
        }

        main {
            width: min(720px, 92vw);
            background: var(--panel);
            border: 1px solid var(--border);
            border-radius: 16px;
            padding: 22px;
            box-shadow: 0 10px 28px rgba(0, 0, 0, .35)
        }

        h1 {
            margin: 0 0 .25rem;
            font-size: 22px
        }

        p.muted {
            margin: .25rem 0 1rem;
            color: var(--muted)
        }

        .row {
            display: flex;
            flex-wrap: wrap;
            gap: 10px;
            align-items: center
        }

        label {
            min-width: 6em
        }

        input,
        select {
            color: var(--fg);
            background: #0a0f1c;
            border: 1px solid var(--border);
            border-radius: 10px;
            padding: 8px 10px;
        }

        button {
            background: var(--accent);
            color: #0a0f1c;
            border: none;
            border-radius: 10px;
            padding: 10px 14px;
            font-weight: 700;
            cursor: pointer
        }

        button:hover {
            filter: brightness(1.06)
        }

        .hint {
            color: var(--muted);
            font-size: 14px;
            margin-top: .75rem
        }

        .grid {
            display: grid;
            gap: 12px
        }
    </style>
</head>

<body>
    <main>
        <h1>はじめまして、お名前は? 今は何時ですか?</h1>
        <p class="muted">入力して「送信」を押すと、時間帯に合わせた挨拶とテーマで結果ページを表示します。</p>

        <!-- GET /app/greet?name=...&hh=..&mm=.. -->
        <form action="/app/greet" method="get" class="grid" id="greetForm">
            <div class="row">
                <label for="name">お名前</label>
                <input id="name" name="name" maxlength="40" placeholder="alice / bob など" required />
            </div>
            <div class="row">
                <label for="hh">時刻</label>
                <select id="hh" name="hh" required></select>
                <span></span>
                <select id="mm" name="mm" required></select>
                <span></span>
            </div>
            <div>
                <button type="submit">送信</button>
            </div>
            <p class="hint">※ このページは WEB サーバ (:8080) からの静的配信です。「送信」クリック後は AP サーバ (:8081) が動的にページ生成します。</p>
        </form>
    </main>

    <script>
        // 時・分の候補を生成し、初期値を現在のクライアント時刻に設定
        (function () {
            const hh = document.getElementById('hh');
            const mm = document.getElementById('mm');
            for (let h = 0; h <= 23; h++) {
                const o = document.createElement('option');
                o.value = String(h);
                o.textContent = String(h).padStart(2, '0');
                hh.appendChild(o);
            }
            for (let m = 0; m <= 59; m++) {
                const o = document.createElement('option');
                o.value = String(m);
                o.textContent = String(m).padStart(2, '0');
                mm.appendChild(o);
            }
            const now = new Date();
            hh.value = String(now.getHours());
            mm.value = String(now.getMinutes());
        })();
    </script>
</body>

</html>

step2 起動方法

$ powershell -File .\backend_step2_websv.ps1  -Port 8080 -Root .\www -AppBase http://localhost:8081
2025-10-28 22:42:50.409 START: http://localhost:8080/
2025-10-28 22:42:50.445 ROOT : D:\var\work\20251005_web_study\step\local\backend\step2_ap\www
2025-10-28 22:42:50.445 STOP : http://localhost:8080/__stop?token=a162aa75297845ccaa9ae3473933b4b4
2025-10-28 22:45:44.854 Web Server STOPPED.
$
$ powershell -File .\backend_step2_apsv.ps1  -Port 8081
2025-10-28 22:42:28.816 [AP SRV] START: http://localhost:8081/
2025-10-28 22:42:28.860 [AP SRV] STOP : http://localhost:8081/__stop?token=57960c1d4e3ba8f2
2025-10-28 22:43:32.790 [AP ACC] [::1]:63782 GET /app/greet?name=test&hh=22&mm=43 130ms -> 200
2025-10-28 22:43:54.078 [AP ACC] [::1]:63782 GET /app/greet?name=alice&hh=10&mm=43 11ms -> 200
2025-10-28 22:44:09.173 [AP ACC] [::1]:63782 GET /app/greet?name=bob&hh=15&mm=44 15ms -> 200
2025-10-28 22:44:37.566 [AP ACC] [::1]:63782 GET /app/greet?name=test&hh=22&mm=44 8ms -> 200
2025-10-28 22:44:48.941 [AP ACC] [::1]:63782 GET /app/greet?name=alice&hh=5&mm=44 15ms -> 200
2025-10-28 22:44:59.922 [AP ACC] [::1]:63782 GET /app/greet?name=bob&hh=14&mm=44 21ms -> 200
2025-10-28 22:45:34.750 [AP ACC] [::1]:63824 GET /__stop?token=57960c1d4e3ba8f2 3ms -> 200
2025-10-28 22:45:34.768 [AP SRV] AP Server STOPPED.
$

step3 DBサーバの連携を確認してみよう

backend_step3_websv.ps1
# backend_step3_websv.ps1
# Webサーバ(静的配信 + /app/* プロキシ)
#  - 静的: ./www 配下
#  - プロキシ: /app/* -> http://localhost:8081
#  - ログ: web_server.log / web_access.log(コンソールにも出力)
#  - 停止: GET /__stop?token=...

param(
  [int]$Port = 8787,
  [string]$Root = ".\www",
  [string]$AppBase = "http://localhost:8081",
  [string]$AppPaths = "/app/",
  [string]$LogDir = ".",
  [switch]$Quiet
)

#=== パス/ログ設定 ===#
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $ScriptDir
$SrvLog = Join-Path $LogDir "web_server.log"
$AccLog = Join-Path $LogDir "web_access.log"

#=== ログ関数 ===#
function Write-ServerLog([string]$msg) {
  $line = ("{0} [WB SRV] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"), $msg)
  Add-Content -Path $SrvLog -Value $line -Encoding UTF8
  if (-not $Quiet) { Write-Host $line }
}
function Write-AccessLog([string]$msg) {
  $line = ("{0} [WB ACC] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"), $msg)
  Add-Content -Path $AccLog -Value $line -Encoding UTF8
  if (-not $Quiet) { Write-Host $line }
}

#=== ユーティリティ ===#
function Get-ContentType([string]$path) {
  $ext = [System.IO.Path]::GetExtension($path).ToLowerInvariant()
  switch ($ext) {
    ".html" { "text/html; charset=utf-8" }
    ".htm" { "text/html; charset=utf-8" }
    ".css" { "text/css; charset=utf-8" }
    ".js" { "application/javascript; charset=utf-8" }
    ".json" { "application/json; charset=utf-8" }
    ".png" { "image/png" }
    ".jpg" { "image/jpeg" }
    ".jpeg" { "image/jpeg" }
    ".gif" { "image/gif" }
    ".svg" { "image/svg+xml" }
    ".ico" { "image/x-icon" }
    ".txt" { "text/plain; charset=utf-8" }
    default { "application/octet-stream" }
  }
}
function Safe-Combine([string]$root, [string]$rel) {
  $full = [System.IO.Path]::GetFullPath((Join-Path $root ($rel.TrimStart("/") -replace "/", "\")))
  $rootFull = [System.IO.Path]::GetFullPath($root)
  if (-not $full.StartsWith($rootFull, [System.StringComparison]::OrdinalIgnoreCase)) {
    return $null
  }
  return $full
}
function Send-Bytes($ctx, [byte[]]$bytes, [string]$contentType, [int]$code = 200) {
  $ctx.Response.StatusCode = $code
  if ($contentType) { $ctx.Response.ContentType = $contentType }
  $ctx.Response.ContentLength64 = $bytes.Length
  $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
  $ctx.Response.OutputStream.Close()
}
function Send-Text($ctx, [string]$text, [int]$code = 200, [string]$contentType = "text/plain; charset=utf-8") {
  $bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
  Send-Bytes $ctx $bytes $contentType $code
}
function Send-File($ctx, [string]$path) {
  try {
    $bytes = [System.IO.File]::ReadAllBytes($path)
    $ct = Get-ContentType $path
    Send-Bytes $ctx $bytes $ct 200
  }
  catch {
    Send-Text $ctx "Internal Server Error" 500
    Write-ServerLog "ERROR Send-File: $($_.Exception.Message)"
  }
}
function ReadAllBytes($stream) {
  if ($null -eq $stream) { return @() }
  $ms = New-Object System.IO.MemoryStream
  $buf = New-Object byte[] 8192
  while (($read = $stream.Read($buf, 0, $buf.Length)) -gt 0) {
    $ms.Write($buf, 0, $read)
  }
  $bytes = $ms.ToArray()
  $ms.Dispose()
  return $bytes
}

#=== Proxy処理 ===#
function Proxy-ToApp($ctx, [string]$targetBase) {
  $reqIn = $ctx.Request
  $resOut = $ctx.Response

  $url = $targetBase.TrimEnd("/") + $reqIn.RawUrl
  $req = [System.Net.HttpWebRequest]::Create($url)
  $req.Method = $reqIn.HttpMethod
  $req.AllowAutoRedirect = $false
  $req.Proxy = $null

  # コピー除外ヘッダ
  $exclude = @(
    "Connection", "Proxy-Connection", "Keep-Alive", "Transfer-Encoding", "TE",
    "Trailer", "Upgrade", "Proxy-Authenticate", "Proxy-Authorization", "Content-Length"
  )

  foreach ($hn in $reqIn.Headers.AllKeys) {
    if ($exclude -contains $hn) { continue }
    switch -Regex ($hn) {
      "^Content-Type$" { $req.ContentType = $reqIn.ContentType; continue }
      "^User-Agent$" { $req.UserAgent = $reqIn.UserAgent; continue }
      "^Accept$" { $req.Accept = $reqIn.Headers["Accept"]; continue }
      "^Referer$" { $req.Referer = $reqIn.Headers["Referer"]; continue }
    }
    try { $req.Headers[$hn] = $reqIn.Headers[$hn] }catch {}
  }

  #=== リクエストボディ ===#
  $body = @()
  $hasBody = $false
  try {
    if ($reqIn.HasEntityBody -and $reqIn.InputStream) {
      if ($reqIn.ContentLength64 -gt 0) {
        $body = ReadAllBytes $reqIn.InputStream
        if ($null -ne $body -and $body.Length -gt 0) { $hasBody = $true }
      }
    }
  }
  catch {
    Write-ServerLog "WARN Proxy BodyRead: $($_.Exception.Message)"
    $body = @()
    $hasBody = $false
  }

  if ($hasBody) {
    try {
      $req.ContentLength = $body.Length
      $rs = $req.GetRequestStream()
      $rs.Write($body, 0, $body.Length)
      $rs.Close()
    }
    catch {
      Write-ServerLog "WARN Proxy BodyWrite: $($_.Exception.Message)"
      $req.ContentLength = 0
    }
  }
  else {
    $req.ContentLength = 0
  }
  $req.ServicePoint.Expect100Continue = $false

  #=== レスポンス受信 ===#
  $statusCode = 502
  try {
    $resp = $req.GetResponse()
    $statusCode = [int]$([System.Net.HttpWebResponse]$resp).StatusCode
    $resOut.StatusCode = $statusCode

    # ヘッダコピー(除外以外)
    foreach ($hn in $resp.Headers.AllKeys) {
      if ($exclude -contains $hn) { continue }
      if ($hn -eq "Content-Length") { continue }
      try { $resOut.Headers[$hn] = $resp.Headers[$hn] } catch {}
    }

    # 本文
    $bytes = @()
    try {
      $rs = $resp.GetResponseStream()
      if ($null -ne $rs) {
        $bytes = ReadAllBytes $rs
        $rs.Close()
      }
    }
    catch {
      Write-ServerLog "WARN Proxy RespRead: $($_.Exception.Message)"
      $bytes = @()
    }
    $resp.Close()

    if ($resp.ContentType) { $resOut.ContentType = $resp.ContentType }
    if ($null -ne $bytes -and $bytes.Length -gt 0) {
      $resOut.ContentLength64 = $bytes.Length
      $resOut.OutputStream.Write($bytes, 0, $bytes.Length)
    }
    else {
      $resOut.ContentLength64 = 0
    }
    $resOut.OutputStream.Close()

  }
  catch [System.Net.WebException] {
    if ($_.Exception.Response) {
      $resp = $_.Exception.Response
      $statusCode = [int]$([System.Net.HttpWebResponse]$resp).StatusCode
      $resOut.StatusCode = $statusCode
      foreach ($hn in $resp.Headers.AllKeys) {
        if ($exclude -contains $hn) { continue }
        if ($hn -eq "Content-Length") { continue }
        try { $resOut.Headers[$hn] = $resp.Headers[$hn] } catch {}
      }
      $bytes = @()
      try {
        $rs = $resp.GetResponseStream()
        if ($null -ne $rs) {
          $bytes = ReadAllBytes $rs
          $rs.Close()
        }
      }
      catch {
        Write-ServerLog "WARN Proxy RespRead(WebEx): $($_.Exception.Message)"
        $bytes = @()
      }
      $resp.Close()
      if ($null -ne $bytes -and $bytes.Length -gt 0) {
        $resOut.ContentLength64 = $bytes.Length
        $resOut.OutputStream.Write($bytes, 0, $bytes.Length)
      }
      else {
        $resOut.ContentLength64 = 0
      }
      $resOut.OutputStream.Close()
    }
    else {
      $statusCode = 502
      Send-Text $ctx "Bad Gateway" 502
    }
  }
  catch {
    $statusCode = 502
    Send-Text $ctx "Bad Gateway" 502
    Write-ServerLog "ERROR Proxy: $($_.Exception.Message)"
  }

  return $statusCode
}

#=== 初期化 ===#
if (-not (Test-Path $Root)) { New-Item -ItemType Directory -Path $Root | Out-Null }
$paths = @()
foreach ($p in $AppPaths.Split(",")) { $q = $p.Trim(); if ($q -ne "") { $paths += $q } }

$listener = New-Object System.Net.HttpListener
$prefix = "http://localhost:$Port/"
$listener.Prefixes.Add($prefix)
$StopToken = -join ((48..57 + 97..102) | Get-Random -Count 32 | ForEach-Object { [char]$_ })
Write-ServerLog "STOP token: $StopToken"

try { $listener.Start() }catch {
  Write-ServerLog "ERROR: Port $Port might be in use. $($_.Exception.Message)"
  throw
}

Write-ServerLog "Web Server START: $prefix Root=$Root AppBase=$AppBase AppPaths=$($paths -join ',')"
Write-ServerLog "Stop URL: ${prefix}__stop?token=$StopToken"
if (-not $Quiet) {
  Write-Host "==> Open: ${prefix}"
  Write-Host "==> Stop: ${prefix}__stop?token=$StopToken"
}

#=== 終了処理 ===#
$onExit = {
  if ($listener.IsListening) { $listener.Stop(); $listener.Close() }
  Write-ServerLog "Web Server STOP"
}
Register-EngineEvent PowerShell.Exiting -Action $onExit | Out-Null

#=== メインループ ===#
while ($listener.IsListening) {
  try { $ctx = $listener.GetContext() }catch { break }
  $sw = [System.Diagnostics.Stopwatch]::StartNew()
  $req = $ctx.Request
  $method = $req.HttpMethod
  $rawUrl = $req.RawUrl
  $path = $req.Url.AbsolutePath
  $status = 200

  try {
    # 停止
    if ($method -eq "GET" -and $path -eq "/__stop") {
      $qs = $req.Url.Query.TrimStart("?") -split "&" | Where-Object { $_ -ne "" }
      $map = @{}
      foreach ($p in $qs) {
        $kv = $p -split "=", 2
        $k = [System.Uri]::UnescapeDataString($kv[0])
        $v = ""
        if ($kv.Count -gt 1) { $v = [System.Uri]::UnescapeDataString($kv[1]) }
        $map[$k] = $v
      }
      if ($map["token"] -eq $StopToken) {
        Send-Text $ctx "Web Server stopping..." 200
        $status = 200
        break
      }
      else {
        Send-Text $ctx "Invalid token" 403
        $status = 403
      }
      continue
    }

    # /app/* プロキシ
    $isApp = $false
    foreach ($p in $paths) {
      if ($path.StartsWith($p, [System.StringComparison]::OrdinalIgnoreCase)) { $isApp = $true; break }
    }
    if ($isApp) {
      $status = Proxy-ToApp $ctx $AppBase
      continue
    }

    # 静的配信
    $rel = $path
    if ($rel -eq "/") { $rel = "/index.html" }
    $full = Safe-Combine $Root $rel
    if ($null -eq $full -or -not (Test-Path $full) -or (Get-Item $full).PSIsContainer) {
      Send-Text $ctx "Not Found" 404
      $status = 404
    }
    else {
      Send-File $ctx $full
      $status = 200
    }

  }
  catch {
    $msg = $_.Exception.Message
    try { Send-Text $ctx "Internal Server Error" 500 }catch {}
    $status = 500
    Write-ServerLog "ERROR: $msg"
  }
  finally {
    $sw.Stop()
    Write-AccessLog ("{0} {1} {2} {3}ms -> {4}" -f $req.RemoteEndPoint, $method, $rawUrl, $sw.ElapsedMilliseconds, $status)
  }
}

$onExit.Invoke()


backend_step3_apsv.ps1
# backend_step3_apsv.ps1
# APサーバ: http://localhost:8081/
# 役割:
#   - /app/todo のHTML画面生成(一覧・追加フォーム・完了切替・削除)
#   - DBサーバ(http://localhost:8082)へのHTTP連携
# - バリデーション: 英数字 + 一般的な日本語(ひら・カナ・漢字・全角/半角記号・CJK記号)+空白のみ許可
#   ・制御文字(\p{C})NG、200文字以内
# 提供エンドポイント:
#   GET  /app/todo             -> タスク一覧HTML
#   POST /app/todo/add         -> 追加(text)
#   POST /app/todo/toggle      -> 完了フラグ反転(id)
#   POST /app/todo/delete      -> 削除(id)
#   GET  /__stop?token=...     -> サーバ停止
# ログ: ap_server.log / ap_access.log(コンソールにも出力)

param(
  [int]$Port = 8081,
  [string]$LogDir = ".",
  [string]$DbBase = "http://localhost:8082",
  [switch]$Quiet
)

#=== 基本パス ===#
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $ScriptDir
$SrvLog = Join-Path $LogDir "ap_server.log"
$AccLog = Join-Path $LogDir "ap_access.log"

#=== ログ ===#
function Write-ServerLog([string]$msg) {
  $line = ("{0} [AP SRV] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"), $msg)
  Add-Content -Path $SrvLog -Value $line -Encoding UTF8
  if (-not $Quiet) { Write-Host $line }
}
function Write-AccessLog([string]$msg) {
  $line = ("{0} [AP ACC] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"), $msg)
  Add-Content -Path $AccLog -Value $line -Encoding UTF8
  if (-not $Quiet) { Write-Host $line }
}

#=== HTTPユーティリティ ===#
function Send-Html($ctx, [string]$html, [int]$code = 200) {
  $bytes = [System.Text.Encoding]::UTF8.GetBytes($html)
  $ctx.Response.StatusCode = $code
  $ctx.Response.ContentType = "text/html; charset=utf-8"
  $ctx.Response.ContentLength64 = $bytes.Length
  $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
  $ctx.Response.OutputStream.Close()
}
function Send-Text($ctx, [string]$text, [int]$code = 200, [string]$contentType = "text/plain; charset=utf-8") {
  $bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
  $ctx.Response.StatusCode = $code
  $ctx.Response.ContentType = $contentType
  $ctx.Response.ContentLength64 = $bytes.Length
  $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
  $ctx.Response.OutputStream.Close()
}
function Send-Redirect($ctx, [string]$location) {
  $ctx.Response.StatusCode = 302
  $ctx.Response.RedirectLocation = $location
  $ctx.Response.OutputStream.Close()
}
function Read-BodyAsString($ctx) {
  $sr = New-Object System.IO.StreamReader($ctx.Request.InputStream, $ctx.Request.ContentEncoding)
  $body = $sr.ReadToEnd()
  $sr.Close()
  return $body
}
function UrlDecode([string]$s) {
  try { return [System.Web.HttpUtility]::UrlDecode($s) }
  catch { return [System.Uri]::UnescapeDataString($s) }
}
function UrlEncode([string]$s) {
  try { return [System.Web.HttpUtility]::UrlEncode($s) }
  catch { return [System.Uri]::EscapeDataString($s) }
}
function Parse-Form([string]$body) {
  $pairs = @{}
  if ([string]::IsNullOrWhiteSpace($body)) { return $pairs }
  foreach ($kv in $body -split "&") {
    if ($kv -eq "") { continue }
    $parts = $kv -split "=", 2
    $k = UrlDecode($parts[0])
    $v = ""
    if ($parts.Count -gt 1) { $v = UrlDecode($parts[1]) }
    $pairs[$k] = $v
  }
  return $pairs
}
function Parse-Query($uri) {
  $qs = @{}
  $q = $uri.Query
  if ([string]::IsNullOrEmpty($q)) { return $qs }
  $q = $q.TrimStart("?")
  foreach ($kv in $q -split "&") {
    if ($kv -eq "") { continue }
    $parts = $kv -split "=", 2
    $k = UrlDecode($parts[0])
    $v = ""
    if ($parts.Count -gt 1) { $v = UrlDecode($parts[1]) }
    $qs[$k] = $v
  }
  return $qs
}

#=== サーバ間通信(DBクライアント) ===#
function Db-GetTasks {
  try {
    $resp = Invoke-WebRequest -UseBasicParsing -Method GET -Uri ($DbBase + "/db/tasks")
    if ($resp.StatusCode -ne 200) { return @() }
    $json = $resp.Content | ConvertFrom-Json
    return @($json)
  }
  catch {
    Write-ServerLog "ERROR Db-GetTasks: $($_.Exception.Message)"
    return @()
  }
}
function Db-AddTask([string]$text) {
  try {
    $resp = Invoke-WebRequest -UseBasicParsing -Method POST -Uri ($DbBase + "/db/tasks") -Body @{ text = $text } -ContentType "application/x-www-form-urlencoded"
    return $resp.StatusCode -eq 200
  }
  catch {
    Write-ServerLog "ERROR Db-AddTask: $($_.Exception.Message)"
    return $false
  }
}
function Db-Toggle([int]$id) {
  try {
    $resp = Invoke-WebRequest -UseBasicParsing -Method POST -Uri ($DbBase + "/db/toggle?id=$id")
    return $resp.StatusCode -eq 200
  }
  catch {
    Write-ServerLog "ERROR Db-Toggle: $($_.Exception.Message)"
    return $false
  }
}
function Db-Delete([int]$id) {
  try {
    $resp = Invoke-WebRequest -UseBasicParsing -Method POST -Uri ($DbBase + "/db/delete?id=$id")
    return $resp.StatusCode -eq 200
  }
  catch {
    Write-ServerLog "ERROR Db-Delete: $($_.Exception.Message)"
    return $false
  }
}

#=== サーバ側バリデーション ===#
function Is-AllowedText([string]$s) {
  if ([string]::IsNullOrWhiteSpace($s)) { return $false }
  if ($s.Length -gt 200) { return $false }              # 長さ制限
  if ($s -match '[\p{C}]') { return $false }            # 制御文字は全面NG

  # 使用可能なUnicode範囲:
  # BasicLatin, Hiragana, Katakana, CJK(漢字), 全角記号(3000–303F), 半角全角フォーム(FF00–FFEF)
  # 以外の文字があればNG
  if ($s -match '[^\p{IsBasicLatin}\p{IsHiragana}\p{IsKatakana}\p{IsCJKUnifiedIdeographs}\u3000-\u303F\uFF00-\uFFEF\s]') {
    return $false
  }

  return $true
}

#=== HTML描画 ===#
function HtmlEscape([string]$s) {
  if ($null -eq $s) { return "" }
  $s = $s -replace "&", "&amp;" -replace "<", "&lt;" -replace ">", "&gt;" -replace '"', "&quot;"
  return $s
}
function Build-TodoHtml($tasks, [string]$flashMsg) {
  @"
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Step3 TODO (AP)</title>
<style>
  :root{ color-scheme: light dark; }
  body{ font-family: system-ui, sans-serif; margin: 24px; }
  h1{ font-size: 20px; margin:0 0 12px; }
  form.add{ display:flex; gap:8px; margin:12px 0 20px; }
  input[type=text]{ flex:1; padding:8px; }
  button{ padding:6px 10px; cursor:pointer; }
  table{ border-collapse: collapse; width:100%; max-width:900px; }
  th,td{ border-bottom: 1px solid #8884; padding:8px; text-align:left; }
  td.actions{ width:120px; }
  .done{ opacity:0.7; text-decoration: line-through; }
  .muted{ color:#888; font-size:12px; }
  .wrap{ word-break: break-word; }
  .flash{ padding:10px 12px; border-radius:6px; margin:8px 0 12px; background:#ffe8e8; color:#a11; border:1px solid #f4bcbc; }
</style>
</head>
<body>
  <h1>Step3: TODO(AP生成)</h1>

  <!-- フラッシュメッセージ -->
  $(if(-not [string]::IsNullOrEmpty($flashMsg)) { '<div class="flash" role="alert">' + (HtmlEscape $flashMsg) + '</div>' } else { '' })

  <form class="add" action="/app/todo/add" method="post" autocomplete="off">
    <input type="text" name="text" placeholder="タスクを入力..." required />
    <button type="submit">追加</button>
    <a href="/app/todo" style="align-self:center">更新</a>
  </form>

  <p class="muted">※ 受け付ける文字:英数字・ひらがな・カタカナ・漢字・一般的な全角/半角記号・空白(200文字以内)</p>

  <table aria-label="タスク一覧">
    <thead>
      <tr><th style="width:60px;">状態</th><th>内容</th><th style="width:180px;">日時</th><th class="actions">操作</th></tr>
    </thead>
    <tbody>
"@ | Out-String | ForEach-Object { $_ }

  foreach ($t in $tasks) {
    $id = [int]$t.id
    $text = HtmlEscape "$($t.text)"
    $created = HtmlEscape "$($t.createdAt)"
    $updated = HtmlEscape "$($t.updatedAt)"
    $isDone = $false; if ($t.done) { $isDone = $true }
    $checked = ""; if ($isDone) { $checked = "checked" }
    $rowClass = ""; if ($isDone) { $rowClass = "done" }

    @"
      <tr>
        <td>
          <form action="/app/todo/toggle" method="post">
            <input type="hidden" name="id" value="$id" />
            <input type="checkbox" onclick="this.form.submit()" $checked />
          </form>
        </td>
        <td class="wrap $rowClass">$text</td>
        <td class="muted">
          <div>作成: $created</div>
          <div>更新: $updated</div>
        </td>
        <td>
          <form action="/app/todo/delete" method="post" onsubmit="return confirm('削除しますか?');">
            <input type="hidden" name="id" value="$id" />
            <button type="submit" title="削除">🗑 削除</button>
          </form>
        </td>
      </tr>
"@ | Out-String | ForEach-Object { $_ }
  }

  @"
    </tbody>
  </table>

  <p class="muted">※ この画面は AP サーバが DB サーバからデータ取得してHTMLを生成しています。</p>
</body>
</html>
"@
}

#=== リスナー起動 ===#
$listener = New-Object System.Net.HttpListener
$prefix = "http://localhost:$Port/"
$listener.Prefixes.Add($prefix)

# 停止トークン
$StopToken = -join ((48..57 + 97..102) | Get-Random -Count 32 | ForEach-Object { [char]$_ })
Write-ServerLog "STOP token: $StopToken"

try {
  $listener.Start()
}
catch {
  Write-ServerLog "ERROR: Port $Port might be in use. $($_.Exception.Message)"
  throw
}

Write-ServerLog "AP Server START: $prefix"
Write-ServerLog "DB Base: $DbBase"
Write-ServerLog "Stop URL: ${prefix}__stop?token=$StopToken"
if (-not $Quiet) {
  Write-Host "==> Open: ${prefix}app/todo"
  Write-Host "==> Stop: ${prefix}__stop?token=$StopToken"
}

# 終了処理
$onExit = {
  if ($listener.IsListening) { $listener.Stop(); $listener.Close() }
  Write-ServerLog "AP Server STOP"
}
Register-EngineEvent PowerShell.Exiting -Action $onExit | Out-Null

#=== ルーティング ===#
while ($listener.IsListening) {
  try {
    $ctx = $listener.GetContext()
  }
  catch {
    break
  }
  $sw = [System.Diagnostics.Stopwatch]::StartNew()
  $req = $ctx.Request
  $method = $req.HttpMethod
  $rawUrl = $req.RawUrl
  $uri = $req.Url
  $path = $uri.AbsolutePath.ToLowerInvariant()
  $status = 200

  try {
    switch -Regex ($method + " " + $path) {
      "^GET /__stop$" {
        $qs = $uri.Query.TrimStart("?") -split "&" | Where-Object { $_ -ne "" }
        $map = @{}
        foreach ($p in $qs) {
          $kv = $p -split "=", 2
          $k = UrlDecode($kv[0])
          $v = ""
          if ($kv.Count -gt 1) { $v = UrlDecode($kv[1]) }
          $map[$k] = $v
        }
        if ($map["token"] -eq $StopToken) {
          Send-Text $ctx "AP Server stopping..." 200
          $status = 200
        }
        else {
          Send-Text $ctx "Invalid token" 403
          $status = 403
        }
        break
      }

      "^GET /app/todo$" {
        $qs = Parse-Query $uri
        $flash = ""
        if ($qs.ContainsKey("msg")) { $flash = "" + $qs["msg"] }
        $tasks = Db-GetTasks
        $html = Build-TodoHtml $tasks $flash
        Send-Html $ctx $html 200
        $status = 200
        break
      }

      "^POST /app/todo/add$" {
        $form = Parse-Form (Read-BodyAsString $ctx)
        $text = ""
        if ($form.ContainsKey("text")) { $text = $form["text"] }

        # サーバ側バリデーション
        if (-not (Is-AllowedText $text)) {
          $msg = UrlEncode "入力可能文字の範囲外です"
          Send-Redirect $ctx "/app/todo?msg=$msg"
          $status = 302
          break
        }

        [void](Db-AddTask $text)
        Send-Redirect $ctx "/app/todo"
        $status = 302
        break
      }

      "^POST /app/todo/toggle$" {
        $form = Parse-Form (Read-BodyAsString $ctx)
        $idStr = $form["id"]
        $id = 0
        if ([int]::TryParse("$idStr", [ref]$id)) {
          [void](Db-Toggle $id)
        }
        else {
          Write-ServerLog "WARN toggle invalid id: $idStr"
        }
        Send-Redirect $ctx "/app/todo"
        $status = 302
        break
      }

      "^POST /app/todo/delete$" {
        $form = Parse-Form (Read-BodyAsString $ctx)
        $idStr = $form["id"]
        $id = 0
        if ([int]::TryParse("$idStr", [ref]$id)) {
          [void](Db-Delete $id)
        }
        else {
          Write-ServerLog "WARN delete invalid id: $idStr"
        }
        Send-Redirect $ctx "/app/todo"
        $status = 302
        break
      }

      default {
        Send-Text $ctx "Not Found" 404
        $status = 404
        break
      }
    }

    if ($method -eq "GET" -and $path -eq "/__stop" -and $status -eq 200) {
      break
    }

  }
  catch {
    $msg = $_.Exception.Message
    try { Send-Text $ctx $msg 500 }catch {}
    $status = 500
    Write-ServerLog "ERROR: $msg"
  }
  finally {
    $sw.Stop()
    Write-AccessLog ("{0} {1} {2} {3}ms -> {4}" -f $req.RemoteEndPoint, $method, $rawUrl, $sw.ElapsedMilliseconds, $status)
  }
}

$onExit.Invoke()


backend_step3_dbsv.ps1
# backend_step3_dbsv.ps1
# 簡易DBサーバ(CSV永続): http://localhost:8082/
# API:
#   GET  /db/tasks              -> 全件をJSON配列
#   POST /db/tasks              -> 追加(text=... or JSON {"text":"..."})
#   POST /db/toggle?id=123      -> 完了フラグ反転
#   POST /db/delete?id=123      -> 完全削除
#   GET  /__stop?token=...      -> 停止
# CSVスキーマ: id,text,done,createdAt,updatedAt
#   id:int / done: "0" | "1" / 日時: "yyyy-MM-dd HH:mm:ss"

param(
  [int]$Port = 8082,
  [string]$DbDir = ".\db",
  [string]$LogDir = ".",
  [string]$CsvName = "tasks.csv",
  [switch]$Quiet
)

#=== 基本パス ===#
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $ScriptDir
$DbPath = Join-Path $DbDir $CsvName
$LockPath = Join-Path $DbDir ($CsvName + ".lock")
$SrvLog = Join-Path $LogDir "db_server.log"
$AccLog = Join-Path $LogDir "db_access.log"

#=== ログ ===#
function Write-ServerLog([string]$msg) {
  $line = ("{0} [DB SRV] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"), $msg)
  Add-Content -Path $SrvLog -Value $line -Encoding UTF8
  if (-not $Quiet) { Write-Host $line }
}
function Write-AccessLog([string]$msg) {
  $line = ("{0} [DB ACC] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"), $msg)
  Add-Content -Path $AccLog -Value $line -Encoding UTF8
  if (-not $Quiet) { Write-Host $line }
}

#=== 初期化 ===#
if (-not (Test-Path $DbDir)) { New-Item -ItemType Directory -Path $DbDir | Out-Null }
if (-not (Test-Path $DbPath)) {
  "id,text,done,createdAt,updatedAt" | Out-File -FilePath $DbPath -Encoding UTF8
  Write-ServerLog "Initialized CSV: $DbPath"
}

#=== 簡易ロック ===#
$global:LockStream = $null
function Acquire-Lock {
  $retry = 0
  while ($true) {
    try {
      $fs = [System.IO.File]::Open($LockPath, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
      $global:LockStream = $fs
      break
    }
    catch {
      Start-Sleep -Milliseconds 50
      $retry++
      if ($retry -ge 200) { throw "Failed to acquire lock: $LockPath" } # ~10秒
    }
  }
}
function Release-Lock {
  if ($global:LockStream) {
    $global:LockStream.Close()
    $global:LockStream.Dispose()
    $global:LockStream = $null
    try { Remove-Item -Path $LockPath -ErrorAction SilentlyContinue }catch {}
  }
}

#=== CSV I/O ===#
function Read-Tasks {
  if (-not (Test-Path $DbPath)) { return @() }
  try {
    return @(Import-Csv -Path $DbPath -Encoding UTF8)   # 配列
  }
  catch {
    Write-ServerLog "ERROR Import-Csv: $($_.Exception.Message)"
    return @()
  }
}
function Write-Tasks($rows) {
  # 単一要素や0件でも安全に
  $arr = @($rows)
  if ($arr.Count -eq 0) {
    # 0件時はヘッダのみ復元
    "id,text,done,createdAt,updatedAt" | Set-Content -Path $DbPath -Encoding UTF8
    return
  }
  $arr | Export-Csv -Path $DbPath -Encoding UTF8 -NoTypeInformation
}
function Next-Id($rows) {
  $arr = @($rows)
  if ($arr.Count -eq 0) { return 1 }
  $max = 0
  foreach ($r in $arr) {
    $v = 0
    [void][int]::TryParse("$($r.id)", [ref]$v)
    if ($v -gt $max) { $max = $v }
  }
  return ($max + 1)
}

#=== HTTP Utility ===#
function Send-Json($ctx, $obj, [int]$code = 200) {
  # パイプを使わず -InputObject を指定すると、空配列でも "[]"
  $json = ConvertTo-Json -InputObject $obj -Depth 6

  # 念のため $null にも対応
  if ($null -eq $json) {
    # 空配列は "[]", それ以外の null は "null"
    if ($obj -is [System.Collections.IEnumerable] -and @($obj).Count -eq 0) {
      $json = "[]"
    }
    else {
      $json = "null"
    }
  }

  $bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
  $ctx.Response.StatusCode = $code
  $ctx.Response.ContentType = "application/json; charset=utf-8"
  $ctx.Response.Headers["Access-Control-Allow-Origin"] = "*"
  $ctx.Response.ContentLength64 = $bytes.Length
  $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
  $ctx.Response.OutputStream.Close()
}
function Send-Text($ctx, [string]$text, [int]$code = 200, [string]$contentType = "text/plain; charset=utf-8") {
  if ($null -eq $text) { $text = "" }  # $nullガード
  $bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
  $ctx.Response.StatusCode = $code
  $ctx.Response.ContentType = $contentType
  $ctx.Response.Headers["Access-Control-Allow-Origin"] = "*"
  $ctx.Response.ContentLength64 = $bytes.Length
  $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
  $ctx.Response.OutputStream.Close()
}
function Read-BodyAsString($ctx) {
  $sr = New-Object System.IO.StreamReader($ctx.Request.InputStream, $ctx.Request.ContentEncoding)
  $body = $sr.ReadToEnd()
  $sr.Close()
  return $body
}
function UrlDecode([string]$s) {
  try { return [System.Web.HttpUtility]::UrlDecode($s) }
  catch { return [System.Uri]::UnescapeDataString($s) }
}
function Parse-Form([string]$body) {
  $pairs = @{}
  if ([string]::IsNullOrWhiteSpace($body)) { return $pairs }
  foreach ($kv in $body -split "&") {
    if ($kv -eq "") { continue }
    $parts = $kv -split "=", 2
    $k = UrlDecode($parts[0])
    $v = ""
    if ($parts.Count -gt 1) { $v = UrlDecode($parts[1]) }
    $pairs[$k] = $v
  }
  return $pairs
}
function Parse-Query($uri) {
  $qs = @{}
  $q = $uri.Query
  if ([string]::IsNullOrEmpty($q)) { return $qs }
  $q = $q.TrimStart("?")
  foreach ($kv in $q -split "&") {
    if ($kv -eq "") { continue }
    $parts = $kv -split "=", 2
    $k = UrlDecode($parts[0])
    $v = ""
    if ($parts.Count -gt 1) { $v = UrlDecode($parts[1]) }
    $qs[$k] = $v
  }
  return $qs
}

#=== ルーティング ===#
$listener = New-Object System.Net.HttpListener
$prefix = "http://localhost:$Port/"
$listener.Prefixes.Add($prefix)

# 停止トークン
$StopToken = -join ((48..57 + 97..102) | Get-Random -Count 32 | ForEach-Object { [char]$_ })
Write-ServerLog "STOP token: $StopToken"

try { $listener.Start() }
catch {
  Write-ServerLog "ERROR: Port $Port might be in use. $($_.Exception.Message)"
  throw
}

Write-ServerLog "DB Server START: $prefix"
Write-ServerLog "CSV: $DbPath"
Write-ServerLog "Stop URL: ${prefix}__stop?token=$StopToken"
if (-not $Quiet) {
  Write-Host "==> GET  ${prefix}db/tasks"
  Write-Host "==> STOP ${prefix}__stop?token=$StopToken"
}

# Ctrl+C でロック解放
$onExit = {
  Release-Lock
  if ($listener.IsListening) { $listener.Stop(); $listener.Close() }
  Write-ServerLog "DB Server STOP"
}
Register-EngineEvent PowerShell.Exiting -Action $onExit | Out-Null

while ($listener.IsListening) {
  try { $ctx = $listener.GetContext() } catch { break }
  $sw = [System.Diagnostics.Stopwatch]::StartNew()
  $req = $ctx.Request
  $res = $ctx.Response
  $method = $req.HttpMethod
  $rawUrl = $req.RawUrl
  $uri = $req.Url
  $path = $uri.AbsolutePath.ToLowerInvariant()
  $query = Parse-Query $uri
  $status = 200

  try {
    switch -Regex ($method + " " + $path) {
      "^GET /__stop$" {
        if ($query["token"] -eq $StopToken) {
          Send-Text $ctx "DB Server stopping..." 200; $status = 200
        }
        else {
          Send-Text $ctx "Invalid token" 403; $status = 403
        }
        break
      }

      "^GET /db/tasks$" {
        Acquire-Lock
        $rows = Read-Tasks
        Release-Lock

        $list = @()
        foreach ($r in $rows) {
          $item = [ordered]@{
            id        = [int]$r.id
            text      = [string]$r.text
            done      = ([string]$r.done -eq "1")
            createdAt = [string]$r.createdAt
            updatedAt = [string]$r.updatedAt
          }
          $list += (New-Object psobject -Property $item)
        }
        Send-Json $ctx $list 200
        $status = 200
        break
      }

      "^POST /db/tasks$" {
        $body = Read-BodyAsString $ctx
        $text = $null
        $ct = "$($req.ContentType)".ToLowerInvariant()
        if ($ct -like "application/json*") {
          try { $obj = $body | ConvertFrom-Json; $text = "$($obj.text)" } catch {}
        }
        else {
          $form = Parse-Form $body
          if ($form.ContainsKey("text")) { $text = "$($form["text"])" }
        }
        if ([string]::IsNullOrWhiteSpace($text)) {
          Send-Json $ctx @{ error = "text is required" } 400; $status = 400; break
        }

        $now = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")

        Acquire-Lock
        try {
          $rows = Read-Tasks
          $id = Next-Id $rows

          $newRow = [pscustomobject]@{
            id        = "$id"
            text      = $text
            done      = "0"
            createdAt = $now
            updatedAt = $now
          }

          $rows = @($rows) + @($newRow)     # ← 常に配列
          Write-Tasks $rows
        }
        finally {
          Release-Lock
        }

        $resp = @{
          id        = $id
          text      = $text
          done      = $false
          createdAt = $now
          updatedAt = $now
        }
        Send-Json $ctx $resp 200
        $status = 200
        break
      }

      "^POST /db/toggle$" {
        $idStr = $query["id"]
        if (-not $idStr) { Send-Json $ctx @{error = "id is required" } 400; $status = 400; break }
        $id = 0
        if (-not [int]::TryParse("$idStr", [ref]$id)) { Send-Json $ctx @{error = "invalid id" } 400; $status = 400; break }

        $now = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
        $updated = $null

        Acquire-Lock
        try {
          $rows = Read-Tasks            # 配列
          $found = $false
          foreach ($r in $rows) {
            if ([int]$r.id -eq $id) {
              $found = $true
              if ($r.done -eq "1") { $r.done = "0" } else { $r.done = "1" }
              $r.updatedAt = $now
              $updated = @{
                id        = $id
                text      = "$($r.text)"
                done      = ($r.done -eq "1")
                createdAt = "$($r.createdAt)"
                updatedAt = $now
              }
              break
            }
          }
          if (-not $found) { Send-Json $ctx @{error = "not found" } 404; $status = 404; break }
          Write-Tasks $rows             # 配列
        }
        finally {
          Release-Lock
        }
        Send-Json $ctx $updated 200
        $status = 200
        break
      }

      "^POST /db/delete$" {
        $idStr = $query["id"]
        if (-not $idStr) { Send-Json $ctx @{error = "id is required" } 400; $status = 400; break }
        $id = 0
        if (-not [int]::TryParse("$idStr", [ref]$id)) { Send-Json $ctx @{error = "invalid id" } 400; $status = 400; break }

        $deleted = $false
        Acquire-Lock
        try {
          $rows = Read-Tasks
          $before = @($rows).Count
          # Where-Object の単一要素→単体オブジェクト化を防止
          $rows = @($rows | Where-Object { [int]$_.id -ne $id })
          $after = $rows.Count

          if ($after -lt $before) {
            $deleted = $true
            Write-Tasks $rows
          }
        }
        finally {
          Release-Lock
        }
        if (-not $deleted) { Send-Json $ctx @{error = "not found" } 404; $status = 404; break }
        Send-Json $ctx @{ ok = $true; id = $id } 200
        $status = 200
        break
      }

      default {
        Send-Text $ctx "Not Found" 404
        $status = 404
        break
      }
    }

    # 停止要求なら抜ける
    if ($method -eq "GET" -and $path -eq "/__stop" -and $status -eq 200 -and $query["token"] -eq $StopToken) {
      break
    }

  }
  catch {
    $msg = $_.Exception.Message
    try { Send-Json $ctx @{ error = $msg } 500 }catch {}
    $status = 500
    Write-ServerLog "ERROR: $msg"
  }
  finally {
    $sw.Stop()
    Write-AccessLog ("{0} {1} {2} {3}ms -> {4}" -f $req.RemoteEndPoint, $method, $rawUrl, $sw.ElapsedMilliseconds, $status)
  }
}

# 終了処理
$onExit.Invoke()

www\index.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Step3 TODO (Web)</title>
<style>
  :root { color-scheme: light dark; }
  body { font-family: system-ui, sans-serif; margin: 32px; line-height: 1.6; }
  h1 { font-size: 22px; margin-bottom: 16px; }
  form { display: flex; gap: 8px; max-width: 600px; margin-bottom: 24px; }
  input[type=text] { flex: 1; padding: 8px; }
  button { padding: 6px 12px; cursor: pointer; }
  a { color: dodgerblue; text-decoration: none; }
  a:hover { text-decoration: underline; }
  footer { margin-top: 40px; font-size: 13px; color: #888; }
</style>
</head>
<body>
  <h1>Step3: TODOアプリ(Webサーバ画面)</h1>

  <p>この画面は <strong>Webサーバ</strong> が配信しています。</p>
  <p>以下のフォームからタスクを追加すると、<br>
     <strong>APサーバ → DBサーバ(永続層 by CSV)</strong> にデータが連携されます。</p>

  <!-- タスク追加フォーム -->
  <form action="/app/todo/add" method="post">
    <input type="text" name="text" placeholder="タスクを入力..." required />
    <button type="submit">追加</button>
  </form>

  <p><a href="/app/todo">▶ タスク一覧を開く</a></p>

  <footer>
    <p>Step3 バックエンド編:Web→AP→DB(CSV永続)連携デモ</p>
  </footer>
</body>
</html>


step3 起動方法


$ powershell -File .\backend_step3_websv.ps1  -Port 8080 -Root .\www -AppBase http://localhost:8081
2025-10-30 00:41:43.985 [WB SRV] STOP token: a2c31950674bde8f
2025-10-30 00:41:44.006 [WB SRV] Web Server START: http://localhost:8080/ Root=.\www AppBase=http://localhost:8081 AppPaths=/app/
2025-10-30 00:41:44.025 [WB SRV] Stop URL: http://localhost:8080/__stop?token=a2c31950674bde8f
==> Open: http://localhost:8080/
==> Stop: http://localhost:8080/__stop?token=a2c31950674bde8f
2025-10-30 00:41:48.501 [WB ACC] [::1]:59481 GET / 111ms -> 200
2025-10-30 00:41:57.471 [WB ACC] [::1]:59481 POST /app/todo/add 640ms -> 302
2025-10-30 00:41:57.722 [WB ACC] [::1]:59481 GET /app/todo 240ms -> 200
2025-10-30 00:42:12.403 [WB ACC] [::1]:59485 POST /app/todo/add 154ms -> 302
2025-10-30 00:42:12.457 [WB ACC] [::1]:59485 GET /app/todo 61ms -> 200
2025-10-30 00:42:17.089 [WB ACC] [::1]:59485 POST /app/todo/toggle 136ms -> 302
2025-10-30 00:42:17.160 [WB ACC] [::1]:59485 GET /app/todo 70ms -> 200
2025-10-30 00:42:23.814 [WB ACC] [::1]:59487 POST /app/todo/delete 91ms -> 302
2025-10-30 00:42:23.903 [WB ACC] [::1]:59487 GET /app/todo 86ms -> 200
2025-10-30 00:42:29.805 [WB ACC] [::1]:59489 POST /app/todo/delete 74ms -> 302
2025-10-30 00:42:29.875 [WB ACC] [::1]:59489 GET /app/todo 70ms -> 200
2025-10-30 00:42:54.231 [WB ACC] [::1]:59491 GET /__stop?token=a2c31950674bde8f 28ms -> 200
2025-10-30 00:42:54.249 [WB SRV] Web Server STOP
$
$ powershell -File .\backend_step3_apsv.ps1  -Port 8081
2025-10-30 00:41:39.571 [AP SRV] STOP token: 1cbe972a834f650d
2025-10-30 00:41:39.607 [AP SRV] AP Server START: http://localhost:8081/
2025-10-30 00:41:39.607 [AP SRV] DB Base: http://localhost:8082
2025-10-30 00:41:39.607 [AP SRV] Stop URL: http://localhost:8081/__stop?token=1cbe972a834f650d
==> Open: http://localhost:8081/app/todo
==> Stop: http://localhost:8081/__stop?token=1cbe972a834f650d
2025-10-30 00:41:57.454 [AP ACC] [::1]:59483 POST /app/todo/add 405ms -> 302
2025-10-30 00:41:57.719 [AP ACC] [::1]:59483 GET /app/todo 227ms -> 200
2025-10-30 00:42:12.401 [AP ACC] [::1]:59483 POST /app/todo/add 147ms -> 302
2025-10-30 00:42:12.457 [AP ACC] [::1]:59483 GET /app/todo 56ms -> 200
2025-10-30 00:42:17.089 [AP ACC] [::1]:59483 POST /app/todo/toggle 131ms -> 302
2025-10-30 00:42:17.160 [AP ACC] [::1]:59483 GET /app/todo 62ms -> 200
2025-10-30 00:42:23.814 [AP ACC] [::1]:59483 POST /app/todo/delete 85ms -> 302
2025-10-30 00:42:23.903 [AP ACC] [::1]:59483 GET /app/todo 82ms -> 200
2025-10-30 00:42:29.805 [AP ACC] [::1]:59483 POST /app/todo/delete 70ms -> 302
2025-10-30 00:42:29.875 [AP ACC] [::1]:59483 GET /app/todo 55ms -> 200
2025-10-30 00:43:07.679 [AP ACC] [::1]:59493 GET /__stop?token=1cbe972a834f650d 15ms -> 200
2025-10-30 00:43:07.696 [AP SRV] AP Server STOP
$

$ powershell -File .\backend_step3_dbsv.ps1  -Port 8082
2025-10-30 00:41:30.772 [DB SRV] Initialized CSV: .\db\tasks.csv
2025-10-30 00:41:30.880 [DB SRV] STOP token: d76e5109b2fca834
2025-10-30 00:41:30.898 [DB SRV] DB Server START: http://localhost:8082/
2025-10-30 00:41:30.898 [DB SRV] CSV: .\db\tasks.csv
2025-10-30 00:41:30.898 [DB SRV] Stop URL: http://localhost:8082/__stop?token=d76e5109b2fca834
==> GET  http://localhost:8082/db/tasks
==> STOP http://localhost:8082/__stop?token=d76e5109b2fca834
2025-10-30 00:41:57.382 [DB ACC] [::1]:59484 POST /db/tasks 225ms -> 200
2025-10-30 00:41:57.523 [DB ACC] [::1]:59484 GET /db/tasks 21ms -> 200
2025-10-30 00:42:12.350 [DB ACC] [::1]:59484 POST /db/tasks 50ms -> 200
2025-10-30 00:42:12.405 [DB ACC] [::1]:59484 GET /db/tasks 3ms -> 200
2025-10-30 00:42:17.035 [DB ACC] [::1]:59484 POST /db/toggle?id=1 37ms -> 200
2025-10-30 00:42:17.109 [DB ACC] [::1]:59484 GET /db/tasks 10ms -> 200
2025-10-30 00:42:23.760 [DB ACC] [::1]:59484 POST /db/delete?id=2 31ms -> 200
2025-10-30 00:42:23.831 [DB ACC] [::1]:59484 GET /db/tasks 8ms -> 200
2025-10-30 00:42:29.761 [DB ACC] [::1]:59484 POST /db/delete?id=1 13ms -> 200
2025-10-30 00:42:29.822 [DB ACC] [::1]:59484 GET /db/tasks 3ms -> 200
2025-10-30 00:43:21.377 [DB ACC] [::1]:59494 GET /__stop?token=d76e5109b2fca834 12ms -> 200
2025-10-30 00:43:21.395 [DB SRV] DB Server STOP
$


step4 webAPIの動作を確認してみよう

backend_step4_websv.ps1
# backend_step3_websv.ps1から変更なし
# Webサーバ(静的配信 + /app/* プロキシ)
#  - 静的: ./www 配下
#  - プロキシ: /app/* -> http://localhost:8081
#  - ログ: web_server.log / web_access.log(コンソールにも出力)
#  - 停止: GET /__stop?token=...

param(
  [int]$Port = 8787,
  [string]$Root = ".\www",
  [string]$AppBase = "http://localhost:8081",
  [string]$AppPaths = "/app/",
  [string]$LogDir = ".",
  [switch]$Quiet
)

#=== パス/ログ設定 ===#
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $ScriptDir
$SrvLog = Join-Path $LogDir "web_server.log"
$AccLog = Join-Path $LogDir "web_access.log"

#=== ログ関数 ===#
function Write-ServerLog([string]$msg) {
  $line = ("{0} [WEB SRV] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"), $msg)
  Add-Content -Path $SrvLog -Value $line -Encoding UTF8
  if (-not $Quiet) { Write-Host $line }
}
function Write-AccessLog([string]$msg) {
  $line = ("{0} [WEB ACC] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"), $msg)
  Add-Content -Path $AccLog -Value $line -Encoding UTF8
  if (-not $Quiet) { Write-Host $line }
}

#=== ユーティリティ ===#
function Get-ContentType([string]$path) {
  $ext = [System.IO.Path]::GetExtension($path).ToLowerInvariant()
  switch ($ext) {
    ".html" { "text/html; charset=utf-8" }
    ".htm" { "text/html; charset=utf-8" }
    ".css" { "text/css; charset=utf-8" }
    ".js" { "application/javascript; charset=utf-8" }
    ".json" { "application/json; charset=utf-8" }
    ".png" { "image/png" }
    ".jpg" { "image/jpeg" }
    ".jpeg" { "image/jpeg" }
    ".gif" { "image/gif" }
    ".svg" { "image/svg+xml" }
    ".ico" { "image/x-icon" }
    ".txt" { "text/plain; charset=utf-8" }
    default { "application/octet-stream" }
  }
}
function Safe-Combine([string]$root, [string]$rel) {
  $full = [System.IO.Path]::GetFullPath((Join-Path $root ($rel.TrimStart("/") -replace "/", "\")))
  $rootFull = [System.IO.Path]::GetFullPath($root)
  if (-not $full.StartsWith($rootFull, [System.StringComparison]::OrdinalIgnoreCase)) {
    return $null
  }
  return $full
}
function Send-Bytes($ctx, [byte[]]$bytes, [string]$contentType, [int]$code = 200) {
  $ctx.Response.StatusCode = $code
  if ($contentType) { $ctx.Response.ContentType = $contentType }
  $ctx.Response.ContentLength64 = $bytes.Length
  $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
  $ctx.Response.OutputStream.Close()
}
function Send-Text($ctx, [string]$text, [int]$code = 200, [string]$contentType = "text/plain; charset=utf-8") {
  $bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
  Send-Bytes $ctx $bytes $contentType $code
}
function Send-File($ctx, [string]$path) {
  try {
    $bytes = [System.IO.File]::ReadAllBytes($path)
    $ct = Get-ContentType $path
    Send-Bytes $ctx $bytes $ct 200
  }
  catch {
    Send-Text $ctx "Internal Server Error" 500
    Write-ServerLog "ERROR Send-File: $($_.Exception.Message)"
  }
}
function ReadAllBytes($stream) {
  if ($null -eq $stream) { return @() }
  $ms = New-Object System.IO.MemoryStream
  $buf = New-Object byte[] 8192
  while (($read = $stream.Read($buf, 0, $buf.Length)) -gt 0) {
    $ms.Write($buf, 0, $read)
  }
  $bytes = $ms.ToArray()
  $ms.Dispose()
  return $bytes
}

#=== Proxy処理 ===#
function Proxy-ToApp($ctx, [string]$targetBase) {
  $reqIn = $ctx.Request
  $resOut = $ctx.Response

  $url = $targetBase.TrimEnd("/") + $reqIn.RawUrl
  $req = [System.Net.HttpWebRequest]::Create($url)
  $req.Method = $reqIn.HttpMethod
  $req.AllowAutoRedirect = $false
  $req.Proxy = $null

  # コピー除外ヘッダ
  $exclude = @(
    "Connection", "Proxy-Connection", "Keep-Alive", "Transfer-Encoding", "TE",
    "Trailer", "Upgrade", "Proxy-Authenticate", "Proxy-Authorization", "Content-Length"
  )

  foreach ($hn in $reqIn.Headers.AllKeys) {
    if ($exclude -contains $hn) { continue }
    switch -Regex ($hn) {
      "^Content-Type$" { $req.ContentType = $reqIn.ContentType; continue }
      "^User-Agent$" { $req.UserAgent = $reqIn.UserAgent; continue }
      "^Accept$" { $req.Accept = $reqIn.Headers["Accept"]; continue }
      "^Referer$" { $req.Referer = $reqIn.Headers["Referer"]; continue }
    }
    try { $req.Headers[$hn] = $reqIn.Headers[$hn] }catch {}
  }

  #=== リクエストボディ ===#
  $body = @()
  $hasBody = $false
  try {
    if ($reqIn.HasEntityBody -and $reqIn.InputStream) {
      if ($reqIn.ContentLength64 -gt 0) {
        $body = ReadAllBytes $reqIn.InputStream
        if ($null -ne $body -and $body.Length -gt 0) { $hasBody = $true }
      }
    }
  }
  catch {
    Write-ServerLog "WARN Proxy BodyRead: $($_.Exception.Message)"
    $body = @()
    $hasBody = $false
  }

  if ($hasBody) {
    try {
      $req.ContentLength = $body.Length
      $rs = $req.GetRequestStream()
      $rs.Write($body, 0, $body.Length)
      $rs.Close()
    }
    catch {
      Write-ServerLog "WARN Proxy BodyWrite: $($_.Exception.Message)"
      $req.ContentLength = 0
    }
  }
  else {
    $req.ContentLength = 0
  }
  $req.ServicePoint.Expect100Continue = $false

  #=== レスポンス受信 ===#
  $statusCode = 502
  try {
    $resp = $req.GetResponse()
    $statusCode = [int]$([System.Net.HttpWebResponse]$resp).StatusCode
    $resOut.StatusCode = $statusCode

    # ヘッダコピー(除外以外)
    foreach ($hn in $resp.Headers.AllKeys) {
      if ($exclude -contains $hn) { continue }
      if ($hn -eq "Content-Length") { continue }
      try { $resOut.Headers[$hn] = $resp.Headers[$hn] } catch {}
    }

    # 本文
    $bytes = @()
    try {
      $rs = $resp.GetResponseStream()
      if ($null -ne $rs) {
        $bytes = ReadAllBytes $rs
        $rs.Close()
      }
    }
    catch {
      Write-ServerLog "WARN Proxy RespRead: $($_.Exception.Message)"
      $bytes = @()
    }
    $resp.Close()

    if ($resp.ContentType) { $resOut.ContentType = $resp.ContentType }
    if ($null -ne $bytes -and $bytes.Length -gt 0) {
      $resOut.ContentLength64 = $bytes.Length
      $resOut.OutputStream.Write($bytes, 0, $bytes.Length)
    }
    else {
      $resOut.ContentLength64 = 0
    }
    $resOut.OutputStream.Close()

  }
  catch [System.Net.WebException] {
    if ($_.Exception.Response) {
      $resp = $_.Exception.Response
      $statusCode = [int]$([System.Net.HttpWebResponse]$resp).StatusCode
      $resOut.StatusCode = $statusCode
      foreach ($hn in $resp.Headers.AllKeys) {
        if ($exclude -contains $hn) { continue }
        if ($hn -eq "Content-Length") { continue }
        try { $resOut.Headers[$hn] = $resp.Headers[$hn] } catch {}
      }
      $bytes = @()
      try {
        $rs = $resp.GetResponseStream()
        if ($null -ne $rs) {
          $bytes = ReadAllBytes $rs
          $rs.Close()
        }
      }
      catch {
        Write-ServerLog "WARN Proxy RespRead(WebEx): $($_.Exception.Message)"
        $bytes = @()
      }
      $resp.Close()
      if ($null -ne $bytes -and $bytes.Length -gt 0) {
        $resOut.ContentLength64 = $bytes.Length
        $resOut.OutputStream.Write($bytes, 0, $bytes.Length)
      }
      else {
        $resOut.ContentLength64 = 0
      }
      $resOut.OutputStream.Close()
    }
    else {
      $statusCode = 502
      Send-Text $ctx "Bad Gateway" 502
    }
  }
  catch {
    $statusCode = 502
    Send-Text $ctx "Bad Gateway" 502
    Write-ServerLog "ERROR Proxy: $($_.Exception.Message)"
  }

  return $statusCode
}

#=== 初期化 ===#
if (-not (Test-Path $Root)) { New-Item -ItemType Directory -Path $Root | Out-Null }
$paths = @()
foreach ($p in $AppPaths.Split(",")) { $q = $p.Trim(); if ($q -ne "") { $paths += $q } }

$listener = New-Object System.Net.HttpListener
$prefix = "http://localhost:$Port/"
$listener.Prefixes.Add($prefix)
$StopToken = -join ((48..57 + 97..102) | Get-Random -Count 32 | ForEach-Object { [char]$_ })
Write-ServerLog "STOP token: $StopToken"

try { $listener.Start() }catch {
  Write-ServerLog "ERROR: Port $Port might be in use. $($_.Exception.Message)"
  throw
}

Write-ServerLog "Web Server START: $prefix Root=$Root AppBase=$AppBase AppPaths=$($paths -join ',')"
Write-ServerLog "Stop URL: ${prefix}__stop?token=$StopToken"
if (-not $Quiet) {
  Write-Host "==> Open: ${prefix}"
  Write-Host "==> Stop: ${prefix}__stop?token=$StopToken"
}

#=== 終了処理 ===#
$onExit = {
  if ($listener.IsListening) { $listener.Stop(); $listener.Close() }
  Write-ServerLog "Web Server STOP"
}
Register-EngineEvent PowerShell.Exiting -Action $onExit | Out-Null

#=== メインループ ===#
while ($listener.IsListening) {
  try { $ctx = $listener.GetContext() }catch { break }
  $sw = [System.Diagnostics.Stopwatch]::StartNew()
  $req = $ctx.Request
  $method = $req.HttpMethod
  $rawUrl = $req.RawUrl
  $path = $req.Url.AbsolutePath
  $status = 200

  try {
    # 停止
    if ($method -eq "GET" -and $path -eq "/__stop") {
      $qs = $req.Url.Query.TrimStart("?") -split "&" | Where-Object { $_ -ne "" }
      $map = @{}
      foreach ($p in $qs) {
        $kv = $p -split "=", 2
        $k = [System.Uri]::UnescapeDataString($kv[0])
        $v = ""
        if ($kv.Count -gt 1) { $v = [System.Uri]::UnescapeDataString($kv[1]) }
        $map[$k] = $v
      }
      if ($map["token"] -eq $StopToken) {
        Send-Text $ctx "Web Server stopping..." 200
        $status = 200
        break
      }
      else {
        Send-Text $ctx "Invalid token" 403
        $status = 403
      }
      continue
    }

    # /app/* プロキシ
    $isApp = $false
    foreach ($p in $paths) {
      if ($path.StartsWith($p, [System.StringComparison]::OrdinalIgnoreCase)) { $isApp = $true; break }
    }
    if ($isApp) {
      $status = Proxy-ToApp $ctx $AppBase
      continue
    }

    # 静的配信
    $rel = $path
    if ($rel -eq "/") { $rel = "/index.html" }
    $full = Safe-Combine $Root $rel
    if ($null -eq $full -or -not (Test-Path $full) -or (Get-Item $full).PSIsContainer) {
      Send-Text $ctx "Not Found" 404
      $status = 404
    }
    else {
      Send-File $ctx $full
      $status = 200
    }

  }
  catch {
    $msg = $_.Exception.Message
    try { Send-Text $ctx "Internal Server Error" 500 }catch {}
    $status = 500
    Write-ServerLog "ERROR: $msg"
  }
  finally {
    $sw.Stop()
    Write-AccessLog ("{0} {1} {2} {3}ms -> {4}" -f $req.RemoteEndPoint, $method, $rawUrl, $sw.ElapsedMilliseconds, $status)
  }
}

$onExit.Invoke()


backend_step4_apisv.ps1
# backend_step4_apisv.ps1
# APIサーバ: http://localhost:8081/api
# 役割:
#   - CORS対応(preflight含む)
#   - 入出力はすべて JSON
#   - バリデーションは Step3 と同一実装を流用(Is-AllowedText)
#   - DBサーバ(http://localhost:8082)へ橋渡し:
#       GET    /api/tasks            -> DB GET  /db/tasks
#       POST   /api/tasks            -> DB POST /db/tasks      (本文: {text})
#       PATCH  /api/tasks/{id}       -> DB POST /db/toggle?id=…
#       DELETE /api/tasks/{id}       -> DB POST /db/delete?id=…
#   - 追加: /health, /__stop?token=...
#
# 使い方(例):
#   powershell -File .\backend_step4_apisv.ps1 -Port 8081 -DbBase "http://localhost:8082" -AllowCorsFrom "http://localhost:8080"
#
param(
  [int]    $Port = 8081,
  [string] $DbBase = "http://localhost:8082",
  [string] $AllowCorsFrom = "http://localhost:8080",
  [switch] $AllowAllCors,
  [switch] $Quiet
)

$ErrorActionPreference = 'Stop'
$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'

# === ログ ===
$ScriptDir = $PSScriptRoot
$SrvLog = Join-Path $ScriptDir 'api_server.log'
$AccLog = Join-Path $ScriptDir 'api_access.log'
function WSrv([string]$m) { $line = ('{0:yyyy-MM-dd HH:mm:ss.fff} [API SRV] {1}' -f (Get-Date), $m); Add-Content $SrvLog $line -Encoding utf8; if (-not $Quiet) { Write-Host $line } }
function WAcc([string]$m) { $line = ('{0:yyyy-MM-dd HH:mm:ss.fff} [API ACC] {1}' -f (Get-Date), $m); Add-Content $AccLog $line -Encoding utf8; if (-not $Quiet) { Write-Host $line } }

# === CORS ===
function Set-Cors($res, [string]$origin) {
  if ($AllowAllCors) { 
    $res.Headers['Access-Control-Allow-Origin'] = '*'
  }
  else {
    if ($origin -and ($origin -ieq $AllowCorsFrom)) {
      $res.Headers['Access-Control-Allow-Origin'] = $origin
    }
    else {
      $res.Headers['Access-Control-Allow-Origin'] = $AllowCorsFrom
    }
  }
  $res.Headers['Vary'] = 'Origin'
}
function Handle-Preflight($ctx) {
  $res = $ctx.Response
  $origin = $ctx.Request.Headers['Origin']
  Set-Cors $res $origin
  $res.Headers['Access-Control-Allow-Methods'] = 'GET,POST,PATCH,DELETE,OPTIONS'
  $reqHdr = $ctx.Request.Headers['Access-Control-Request-Headers']
  if ($reqHdr) { $res.Headers['Access-Control-Allow-Headers'] = $reqHdr } else { $res.Headers['Access-Control-Allow-Headers'] = 'Content-Type' }
  $res.StatusCode = 204
  $res.ContentLength64 = 0
  $res.OutputStream.Close()
}

# === 共通 I/O ===
function Read-BodyUtf8($ctx) {
  $sr = New-Object IO.StreamReader($ctx.Request.InputStream, [Text.Encoding]::UTF8)
  $b = $sr.ReadToEnd()
  $sr.Close()
  return $b
}
function Send-Json($ctx, $obj, [int]$code = 200) {
  $json = $obj | ConvertTo-Json -Depth 8
  $bytes = [Text.Encoding]::UTF8.GetBytes($json)
  $res = $ctx.Response
  $res.StatusCode = $code
  $res.ContentType = 'application/json; charset=utf-8'
  Set-Cors $res $ctx.Request.Headers['Origin']
  $res.ContentLength64 = $bytes.Length
  $res.OutputStream.Write($bytes, 0, $bytes.Length)
  $res.OutputStream.Close()
}
function Send-Text($ctx, [string]$s, [int]$code = 200) {
  $bytes = [Text.Encoding]::UTF8.GetBytes($s)
  $res = $ctx.Response
  $res.StatusCode = $code
  $res.ContentType = 'text/plain; charset=utf-8'
  Set-Cors $res $ctx.Request.Headers['Origin']
  $res.ContentLength64 = $bytes.Length
  $res.OutputStream.Write($bytes, 0, $bytes.Length)
  $res.OutputStream.Close()
}

# === Step3 から流用: バリデーション ===
function Is-AllowedText([string]$s) {
  if ([string]::IsNullOrWhiteSpace($s)) { return $false }
  if ($s.Length -gt 200) { return $false }              
  if ($s -match '[\p{C}]') { return $false }            

  if ($s -match '[^\p{IsBasicLatin}\p{IsHiragana}\p{IsKatakana}\p{IsCJKUnifiedIdeographs}\u3000-\u303F\uFF00-\uFFEF\s]') {
    return $false
  }
  return $true
}

# === URLエンコード文字列を安全にパース ===
function Parse-FormUrlEncoded([string]$body) {
  $map = @{}
  foreach ($kv in $body -split '&') {
    if ([string]::IsNullOrEmpty($kv)) { continue }
    $p = $kv -split '=', 2
    $k = [Uri]::UnescapeDataString(($p[0] -replace '\+', '%20'))
    $v = ''
    if ($p.Count -gt 1) {
      $v = [Uri]::UnescapeDataString(($p[1] -replace '\+', '%20'))
    }
    $map[$k] = $v
  }
  return $map
}

# === DB クライアント ===
function Db-GetTasks {
  try {
    $resp = Invoke-WebRequest -UseBasicParsing -Method GET -Uri ($DbBase + "/db/tasks")
    if ($resp.StatusCode -ne 200) { return @() }
    return ($resp.Content | ConvertFrom-Json)
  }
  catch { WSrv "ERROR Db-GetTasks: $($_.Exception.Message)"; return @() }
}
function Db-AddTask([string]$text) {
  try {
    $body = @{ text = $text }
    $resp = Invoke-WebRequest -UseBasicParsing -Method POST -Uri ($DbBase + "/db/tasks") -Body $body -ContentType "application/x-www-form-urlencoded"
    return ($resp.Content | ConvertFrom-Json)
  }
  catch { WSrv "ERROR Db-AddTask: $($_.Exception.Message)"; return $null }
}
function Db-Toggle([int]$id) {
  try {
    $resp = Invoke-WebRequest -UseBasicParsing -Method POST -Uri ($DbBase + "/db/toggle?id=$id")
    return ($resp.Content | ConvertFrom-Json)
  }
  catch { WSrv "ERROR Db-Toggle: $($_.Exception.Message)"; return $null }
}
function Db-Delete([int]$id) {
  try {
    $resp = Invoke-WebRequest -UseBasicParsing -Method POST -Uri ($DbBase + "/db/delete?id=$id")
    return ($resp.Content | ConvertFrom-Json)
  }
  catch { WSrv "ERROR Db-Delete: $($_.Exception.Message)"; return $null }
}

# === HttpListener 起動 ===
$listener = [System.Net.HttpListener]::new()
$prefix = "http://localhost:$Port/"
$listener.Prefixes.Add($prefix)
$StopToken = -join ((48..57 + 97..102) | Get-Random -Count 32 | ForEach-Object { [char]$_ })

try { $listener.Start() } catch { WSrv "ERROR: Port $Port busy? $($_.Exception.Message)"; throw }

WSrv "API Server START: ${prefix} (DbBase=$DbBase)"
WSrv "CORS: " + ($(if ($AllowAllCors) { "*" }else { $AllowCorsFrom }))
WSrv "Stop URL: ${prefix}__stop?token=$StopToken"

try {
  while ($listener.IsListening) {
    try { $ctx = $listener.GetContext() } catch { break }
    $sw = [Diagnostics.Stopwatch]::StartNew()
    $req = $ctx.Request; $res = $ctx.Response
    $method = $req.HttpMethod
    $path = $req.Url.AbsolutePath
    $raw = $req.RawUrl
    $status = 200

    try {
      if ($method -eq 'OPTIONS') {
        Handle-Preflight $ctx
        $status = 204
        continue
      }

      if ($method -eq 'GET' -and $path -eq '/health') {
        Send-Json $ctx @{status = 'ok' }
        $status = 200; continue
      }

      if ($method -eq 'GET' -and $path -eq '/__stop') {
        $qs = [System.Web.HttpUtility]::ParseQueryString($req.Url.Query)
        if ($qs['token'] -eq $StopToken) {
          Send-Text $ctx "API server stopping..." 200
          $status = 200; break
        }
        else {
          Send-Text $ctx "Invalid token" 403
          $status = 403; continue
        }
      }

      switch -Regex ($method + ' ' + $path) {

        '^GET /api/tasks$' {
          $list = Db-GetTasks
          Send-Json $ctx @{ tasks = @($list) }
          $status = 200; break
        }

        '^POST /api/tasks$' {
          $text = $null
          $ct = ('' + $req.ContentType).ToLowerInvariant()
          $body = Read-BodyUtf8 $ctx
          if ($ct -like 'application/json*') {
            try { $text = ( ($body | ConvertFrom-Json).text + '' ) } catch {}
          }
          else {
            $form = Parse-FormUrlEncoded $body
            $text = '' + $form['text']
          }

          $reasons = @()
          if ([string]::IsNullOrWhiteSpace($text)) { $reasons += 'テキストは必須です。' }
          if ($text -and $text.Length -gt 200) { $reasons += '200文字以内で入力してください。' }
          if ($text -and (-not (Is-AllowedText $text))) { $reasons += '使用できない文字が含まれています。' }

          if ($reasons.Count -gt 0) {
            Send-Json $ctx @{ error = 'validation failed'; reasons = $reasons } 400
            $status = 400; break
          }

          $created = Db-AddTask $text
          if ($null -eq $created) { Send-Json $ctx @{ error = 'db error' } 500; $status = 500; break }
          Send-Json $ctx $created 200
          $status = 200; break
        }

        '^PATCH /api/tasks/(\d+)$' {
          $id = [int]([Text.RegularExpressions.Regex]::Match($path, '\d+').Value)
          $updated = Db-Toggle $id
          if ($null -eq $updated) { Send-Json $ctx @{ error = 'not found' } 404; $status = 404; break }
          Send-Json $ctx $updated 200
          $status = 200; break
        }

        '^DELETE /api/tasks/(\d+)$' {
          $id = [int]([Text.RegularExpressions.Regex]::Match($path, '\d+').Value)
          $resp = Db-Delete $id
          if ($null -eq $resp) { Send-Json $ctx @{ error = 'not found' } 404; $status = 404; break }
          Send-Json $ctx @{ ok = $true; id = $id } 200
          $status = 200; break
        }

        default {
          Send-Json $ctx @{ error = 'not found' } 404
          $status = 404; break
        }
      }
    }
    catch {
      $msg = $_.Exception.Message
      try { Send-Json $ctx @{ error = $msg } 500 } catch {}
      $status = 500
      WSrv "ERROR: $msg"
    }
    finally {
      $sw.Stop()
      WAcc ("{0} {1} {2} {3}ms -> {4}" -f $req.RemoteEndPoint, $method, $raw, $sw.ElapsedMilliseconds, $status)
    }
  }
}
finally {
  if ($listener.IsListening) { $listener.Stop(); $listener.Close() }
  WSrv "API Server STOP"
}

backend_step4_dbsv.ps1
# backend_step3_dbsv.ps1から変更なし
# 簡易DBサーバ(CSV永続): http://localhost:8082/
# API:
#   GET  /db/tasks              -> 全件をJSON配列
#   POST /db/tasks              -> 追加(text=... or JSON {"text":"..."})
#   POST /db/toggle?id=123      -> 完了フラグ反転
#   POST /db/delete?id=123      -> 完全削除
#   GET  /__stop?token=...      -> 停止
# CSVスキーマ: id,text,done,createdAt,updatedAt
#   id:int / done: "0" | "1" / 日時: "yyyy-MM-dd HH:mm:ss"

param(
  [int]$Port = 8082,
  [string]$DbDir = ".\db",
  [string]$LogDir = ".",
  [string]$CsvName = "tasks.csv",
  [switch]$Quiet
)

#=== 基本パス ===#
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $ScriptDir
$DbPath = Join-Path $DbDir $CsvName
$LockPath = Join-Path $DbDir ($CsvName + ".lock")
$SrvLog = Join-Path $LogDir "db_server.log"
$AccLog = Join-Path $LogDir "db_access.log"

#=== ログ ===#
function Write-ServerLog([string]$msg) {
  $line = ("{0} [DB SRV] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"), $msg)
  Add-Content -Path $SrvLog -Value $line -Encoding UTF8
  if (-not $Quiet) { Write-Host $line }
}
function Write-AccessLog([string]$msg) {
  $line = ("{0} [DB ACC] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"), $msg)
  Add-Content -Path $AccLog -Value $line -Encoding UTF8
  if (-not $Quiet) { Write-Host $line }
}

#=== 初期化 ===#
if (-not (Test-Path $DbDir)) { New-Item -ItemType Directory -Path $DbDir | Out-Null }
if (-not (Test-Path $DbPath)) {
  "id,text,done,createdAt,updatedAt" | Out-File -FilePath $DbPath -Encoding UTF8
  Write-ServerLog "Initialized CSV: $DbPath"
}

#=== 簡易ロック ===#
$global:LockStream = $null
function Acquire-Lock {
  $retry = 0
  while ($true) {
    try {
      $fs = [System.IO.File]::Open($LockPath, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
      $global:LockStream = $fs
      break
    }
    catch {
      Start-Sleep -Milliseconds 50
      $retry++
      if ($retry -ge 200) { throw "Failed to acquire lock: $LockPath" } # ~10秒
    }
  }
}
function Release-Lock {
  if ($global:LockStream) {
    $global:LockStream.Close()
    $global:LockStream.Dispose()
    $global:LockStream = $null
    try { Remove-Item -Path $LockPath -ErrorAction SilentlyContinue }catch {}
  }
}

#=== CSV I/O ===#
function Read-Tasks {
  if (-not (Test-Path $DbPath)) { return @() }
  try {
    return @(Import-Csv -Path $DbPath -Encoding UTF8)   # 配列
  }
  catch {
    Write-ServerLog "ERROR Import-Csv: $($_.Exception.Message)"
    return @()
  }
}
function Write-Tasks($rows) {
  # 単一要素や0件でも安全に
  $arr = @($rows)
  if ($arr.Count -eq 0) {
    # 0件時はヘッダのみ復元
    "id,text,done,createdAt,updatedAt" | Set-Content -Path $DbPath -Encoding UTF8
    return
  }
  $arr | Export-Csv -Path $DbPath -Encoding UTF8 -NoTypeInformation
}
function Next-Id($rows) {
  $arr = @($rows)
  if ($arr.Count -eq 0) { return 1 }
  $max = 0
  foreach ($r in $arr) {
    $v = 0
    [void][int]::TryParse("$($r.id)", [ref]$v)
    if ($v -gt $max) { $max = $v }
  }
  return ($max + 1)
}

#=== HTTP Utility ===#
function Send-Json($ctx, $obj, [int]$code = 200) {
  # パイプを使わず -InputObject を指定すると、空配列でも "[]"
  $json = ConvertTo-Json -InputObject $obj -Depth 6

  # 念のため $null にも対応
  if ($null -eq $json) {
    # 空配列は "[]", それ以外の null は "null"
    if ($obj -is [System.Collections.IEnumerable] -and @($obj).Count -eq 0) {
      $json = "[]"
    }
    else {
      $json = "null"
    }
  }

  $bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
  $ctx.Response.StatusCode = $code
  $ctx.Response.ContentType = "application/json; charset=utf-8"
  $ctx.Response.Headers["Access-Control-Allow-Origin"] = "*"
  $ctx.Response.ContentLength64 = $bytes.Length
  $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
  $ctx.Response.OutputStream.Close()
}
function Send-Text($ctx, [string]$text, [int]$code = 200, [string]$contentType = "text/plain; charset=utf-8") {
  if ($null -eq $text) { $text = "" }  # $nullガード
  $bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
  $ctx.Response.StatusCode = $code
  $ctx.Response.ContentType = $contentType
  $ctx.Response.Headers["Access-Control-Allow-Origin"] = "*"
  $ctx.Response.ContentLength64 = $bytes.Length
  $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
  $ctx.Response.OutputStream.Close()
}
function Read-BodyAsString($ctx) {
  $sr = New-Object System.IO.StreamReader($ctx.Request.InputStream, $ctx.Request.ContentEncoding)
  $body = $sr.ReadToEnd()
  $sr.Close()
  return $body
}
function UrlDecode([string]$s) {
  try { return [System.Web.HttpUtility]::UrlDecode($s) }
  catch { return [System.Uri]::UnescapeDataString($s) }
}
function Parse-Form([string]$body) {
  $pairs = @{}
  if ([string]::IsNullOrWhiteSpace($body)) { return $pairs }
  foreach ($kv in $body -split "&") {
    if ($kv -eq "") { continue }
    $parts = $kv -split "=", 2
    $k = UrlDecode($parts[0])
    $v = ""
    if ($parts.Count -gt 1) { $v = UrlDecode($parts[1]) }
    $pairs[$k] = $v
  }
  return $pairs
}
function Parse-Query($uri) {
  $qs = @{}
  $q = $uri.Query
  if ([string]::IsNullOrEmpty($q)) { return $qs }
  $q = $q.TrimStart("?")
  foreach ($kv in $q -split "&") {
    if ($kv -eq "") { continue }
    $parts = $kv -split "=", 2
    $k = UrlDecode($parts[0])
    $v = ""
    if ($parts.Count -gt 1) { $v = UrlDecode($parts[1]) }
    $qs[$k] = $v
  }
  return $qs
}

#=== ルーティング ===#
$listener = New-Object System.Net.HttpListener
$prefix = "http://localhost:$Port/"
$listener.Prefixes.Add($prefix)

# 停止トークン
$StopToken = -join ((48..57 + 97..102) | Get-Random -Count 32 | ForEach-Object { [char]$_ })
Write-ServerLog "STOP token: $StopToken"

try { $listener.Start() }
catch {
  Write-ServerLog "ERROR: Port $Port might be in use. $($_.Exception.Message)"
  throw
}

Write-ServerLog "DB Server START: $prefix"
Write-ServerLog "CSV: $DbPath"
Write-ServerLog "Stop URL: ${prefix}__stop?token=$StopToken"
if (-not $Quiet) {
  Write-Host "==> GET  ${prefix}db/tasks"
  Write-Host "==> STOP ${prefix}__stop?token=$StopToken"
}

# Ctrl+C でロック解放
$onExit = {
  Release-Lock
  if ($listener.IsListening) { $listener.Stop(); $listener.Close() }
  Write-ServerLog "DB Server STOP"
}
Register-EngineEvent PowerShell.Exiting -Action $onExit | Out-Null

while ($listener.IsListening) {
  try { $ctx = $listener.GetContext() } catch { break }
  $sw = [System.Diagnostics.Stopwatch]::StartNew()
  $req = $ctx.Request
  $res = $ctx.Response
  $method = $req.HttpMethod
  $rawUrl = $req.RawUrl
  $uri = $req.Url
  $path = $uri.AbsolutePath.ToLowerInvariant()
  $query = Parse-Query $uri
  $status = 200

  try {
    switch -Regex ($method + " " + $path) {
      "^GET /__stop$" {
        if ($query["token"] -eq $StopToken) {
          Send-Text $ctx "DB Server stopping..." 200; $status = 200
        }
        else {
          Send-Text $ctx "Invalid token" 403; $status = 403
        }
        break
      }

      "^GET /db/tasks$" {
        Acquire-Lock
        $rows = Read-Tasks
        Release-Lock

        $list = @()
        foreach ($r in $rows) {
          $item = [ordered]@{
            id        = [int]$r.id
            text      = [string]$r.text
            done      = ([string]$r.done -eq "1")
            createdAt = [string]$r.createdAt
            updatedAt = [string]$r.updatedAt
          }
          $list += (New-Object psobject -Property $item)
        }
        Send-Json $ctx $list 200
        $status = 200
        break
      }

      "^POST /db/tasks$" {
        $body = Read-BodyAsString $ctx
        $text = $null
        $ct = "$($req.ContentType)".ToLowerInvariant()
        if ($ct -like "application/json*") {
          try { $obj = $body | ConvertFrom-Json; $text = "$($obj.text)" } catch {}
        }
        else {
          $form = Parse-Form $body
          if ($form.ContainsKey("text")) { $text = "$($form["text"])" }
        }
        if ([string]::IsNullOrWhiteSpace($text)) {
          Send-Json $ctx @{ error = "text is required" } 400; $status = 400; break
        }

        $now = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")

        Acquire-Lock
        try {
          $rows = Read-Tasks
          $id = Next-Id $rows

          $newRow = [pscustomobject]@{
            id        = "$id"
            text      = $text
            done      = "0"
            createdAt = $now
            updatedAt = $now
          }

          $rows = @($rows) + @($newRow)     # ← 常に配列
          Write-Tasks $rows
        }
        finally {
          Release-Lock
        }

        $resp = @{
          id        = $id
          text      = $text
          done      = $false
          createdAt = $now
          updatedAt = $now
        }
        Send-Json $ctx $resp 200
        $status = 200
        break
      }

      "^POST /db/toggle$" {
        $idStr = $query["id"]
        if (-not $idStr) { Send-Json $ctx @{error = "id is required" } 400; $status = 400; break }
        $id = 0
        if (-not [int]::TryParse("$idStr", [ref]$id)) { Send-Json $ctx @{error = "invalid id" } 400; $status = 400; break }

        $now = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
        $updated = $null

        Acquire-Lock
        try {
          $rows = Read-Tasks            # 配列
          $found = $false
          foreach ($r in $rows) {
            if ([int]$r.id -eq $id) {
              $found = $true
              if ($r.done -eq "1") { $r.done = "0" } else { $r.done = "1" }
              $r.updatedAt = $now
              $updated = @{
                id        = $id
                text      = "$($r.text)"
                done      = ($r.done -eq "1")
                createdAt = "$($r.createdAt)"
                updatedAt = $now
              }
              break
            }
          }
          if (-not $found) { Send-Json $ctx @{error = "not found" } 404; $status = 404; break }
          Write-Tasks $rows             # 配列
        }
        finally {
          Release-Lock
        }
        Send-Json $ctx $updated 200
        $status = 200
        break
      }

      "^POST /db/delete$" {
        $idStr = $query["id"]
        if (-not $idStr) { Send-Json $ctx @{error = "id is required" } 400; $status = 400; break }
        $id = 0
        if (-not [int]::TryParse("$idStr", [ref]$id)) { Send-Json $ctx @{error = "invalid id" } 400; $status = 400; break }

        $deleted = $false
        Acquire-Lock
        try {
          $rows = Read-Tasks
          $before = @($rows).Count
          # Where-Object の単一要素→単体オブジェクト化を防止
          $rows = @($rows | Where-Object { [int]$_.id -ne $id })
          $after = $rows.Count

          if ($after -lt $before) {
            $deleted = $true
            Write-Tasks $rows
          }
        }
        finally {
          Release-Lock
        }
        if (-not $deleted) { Send-Json $ctx @{error = "not found" } 404; $status = 404; break }
        Send-Json $ctx @{ ok = $true; id = $id } 200
        $status = 200
        break
      }

      default {
        Send-Text $ctx "Not Found" 404
        $status = 404
        break
      }
    }

    # 停止要求なら抜ける
    if ($method -eq "GET" -and $path -eq "/__stop" -and $status -eq 200 -and $query["token"] -eq $StopToken) {
      break
    }

  }
  catch {
    $msg = $_.Exception.Message
    try { Send-Json $ctx @{ error = $msg } 500 }catch {}
    $status = 500
    Write-ServerLog "ERROR: $msg"
  }
  finally {
    $sw.Stop()
    Write-AccessLog ("{0} {1} {2} {3}ms -> {4}" -f $req.RemoteEndPoint, $method, $rawUrl, $sw.ElapsedMilliseconds, $status)
  }
}

# 終了処理
$onExit.Invoke()


www\index.html
<!doctype html>
<html lang="ja">

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Step4: TODO(ブラウザ→API→DB / CORS学習)</title>
  <style>
    :root {
      color-scheme: light dark;
    }

    body {
      font-family: system-ui, -apple-system, Segoe UI, Roboto, Noto Sans JP, sans-serif;
      margin: 32px;
      line-height: 1.6;
    }

    h1 {
      font-size: 22px;
      margin: 0 0 12px;
    }

    p.lead {
      margin: 0 0 18px;
      color: #bbb;
    }

    form.add {
      display: flex;
      gap: 8px;
      margin: 12px 0 14px;
    }

    input[type=text] {
      flex: 1;
      padding: 10px;
      border-radius: 8px;
      border: 1px solid #4443;
      background: transparent;
      color: inherit;
    }

    button {
      padding: 10px 14px;
      border-radius: 10px;
      border: 1px solid #4443;
      background: #6663;
      color: inherit;
      cursor: pointer;
    }

    .hint {
      font-size: 12px;
      color: #888;
      margin: 4px 0 10px;
    }

    .toolbar {
      display: flex;
      gap: 12px;
      align-items: center;
      margin: 4px 0 10px;
      flex-wrap: wrap;
    }

    .ok {
      background: #19c37d22;
      border: 1px solid #19c37d55;
      color: #19c37d;
      padding: 4px 8px;
      border-radius: 999px;
      font-size: 12px;
    }

    .err {
      background: #ff4d4d22;
      border: 1px solid #ff4d4d55;
      color: #ff8080;
      padding: 8px 10px;
      border-radius: 10px;
      margin: 10px 0;
      display: none;
      white-space: pre-wrap;
    }

    table {
      width: 100%;
      max-width: 980px;
      border-collapse: collapse;
    }

    th,
    td {
      padding: 10px 8px;
      border-bottom: 1px solid #4443;
    }

    th {
      text-align: left;
      color: #bbb;
      font-weight: 600;
    }

    td.meta {
      white-space: nowrap;
      font-variant-numeric: tabular-nums;
      color: #aaa;
      font-size: 12px;
    }

    .done {
      opacity: .6;
      text-decoration: line-through;
    }

    .right {
      text-align: right;
    }

    .api {
      font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
    }

    .foot {
      margin-top: 16px;
      font-size: 12px;
      color: #888;
    }
  </style>
</head>

<body>
  <h1>Step4: TODO(ブラウザ→API→DB / CORS学習)</h1>
  <p class="lead">
    この画面は <strong>Webサーバ</strong> が配信しています。フォーム操作は <strong>APIサーバ</strong><code>fetch</code> で直接呼び出し、
    <strong>DBサーバ(CSV永続)</strong> とデータ連携します。
  </p>

  <div class="toolbar">
    <span>API:</span>
    <code id="apiBase" class="api">http://localhost:8081/api</code>
    <span id="conn" class="ok" style="display:none">通信OK</span>
    <button type="button" id="reload" title="一覧再読込">再読込</button>
  </div>

  <form id="addForm" class="add">
    <input id="taskText" type="text" placeholder="タスクを入力..." maxlength="200" autocomplete="off" required />
    <button type="submit">追加</button>
  </form>
  <div class="hint">※ 入力は「英数字・ひらがな・カタカナ・漢字・一般的な全角/半角記号・空白」に限定(200文字以内)。</div>

  <div id="error" class="err"></div>

  <table aria-label="todo-list">
    <thead>
      <tr>
        <th style="width:72px;">状態</th>
        <th>内容</th>
        <th style="width:260px;">日時</th>
        <th style="width:120px;" class="right">操作</th>
      </tr>
    </thead>
    <tbody id="tbody"></tbody>
  </table>

  <div class="foot">※ このページは WEB サーバ (:8080) からの静的配信後、APIサーバ (:8081)レスポンスのJSONを用いて<strong>ブラウザ側でHTML反映</strong>しています。</div>

  <script>
    (() => {
      const API_BASE = 'http://localhost:8081/api'; // 必要なら変更
      const api = (p) => `${API_BASE}${p}`;
      const $ = (sel) => document.querySelector(sel);

      const $tbody = $('#tbody');
      const $error = $('#error');
      const $conn = $('#conn');
      const $text = $('#taskText');
      const $form = $('#addForm');

      function showError(m) { $error.textContent = m || ''; $error.style.display = m ? 'block' : 'none'; }
      function toBool01(v) { return (v === true || v === 1 || v === "1") ? 1 : 0; }
      function escapeHtml(s) { return String(s).replace(/[&<>"']/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m])); }

      async function call(path, opt = {}) {
        showError('');
        try {
          const res = await fetch(api(path), opt);
          if (!res.ok) {
            const t = await res.text().catch(() => '');
            // 400系で API が {error, reasons} を返したら見やすく
            try {
              const j = JSON.parse(t);
              if (j && j.error) {
                const detail = Array.isArray(j.reasons) ? ("\n- " + j.reasons.join("\n- ")) : "";
                throw new Error(`${j.error}${detail}`);
              }
            } catch (_) { }
            throw new Error(`HTTP ${res.status} ${res.statusText} - ${t}`);
          }
          if (res.status === 204) return null;
          return await res.json();
        } catch (e) {
          const msg = e && e.message ? e.message : String(e);
          showError(`API呼び出しに失敗しました:\n${msg}`);
          throw e;
        }
      }

      // CRUD
      async function listTasks() {
        const data = await call('/tasks', { method: 'GET' });
        const rows = Array.isArray(data?.tasks) ? data.tasks : (Array.isArray(data) ? data : []);
        render(rows);
      }
      async function addTask(text) {
        return await call('/tasks', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ text })
        });
      }
      async function toggleTask(id) {
        return await call(`/tasks/${encodeURIComponent(id)}`, {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' }
        });
      }
      async function deleteTask(id) {
        return await call(`/tasks/${encodeURIComponent(id)}`, { method: 'DELETE' });
      }

      // 描画
      function render(rows) {
        $tbody.innerHTML = '';
        rows.forEach(r => {
          const id = r.id;
          const done = toBool01(r.done);
          const tr = document.createElement('tr');

          const tdState = document.createElement('td');
          const cb = document.createElement('input');
          cb.type = 'checkbox';
          cb.checked = !!done;
          cb.addEventListener('change', async () => { await toggleTask(id); await listTasks(); });
          tdState.appendChild(cb);
          tr.appendChild(tdState);

          const tdText = document.createElement('td');
          tdText.textContent = r.text ?? '';
          if (done) tdText.classList.add('done');
          tr.appendChild(tdText);

          const tdMeta = document.createElement('td');
          tdMeta.className = 'meta';
          tdMeta.innerHTML = `作成: ${escapeHtml(r.createdAt ?? '')}<br>更新: ${escapeHtml(r.updatedAt ?? '')}`;
          tr.appendChild(tdMeta);

          const tdOps = document.createElement('td');
          tdOps.className = 'right';
          const btnDel = document.createElement('button');
          btnDel.textContent = '🗑 削除';
          btnDel.addEventListener('click', async () => {
            if (!confirm('削除しますか?')) return;
            await deleteTask(id);
            await listTasks();
          });
          tdOps.appendChild(btnDel);
          tr.appendChild(tdOps);

          $tbody.appendChild(tr);
        });
        $conn.style.display = 'inline-block';
      }

      // イベント
      $('#reload').addEventListener('click', listTasks);
      $form.addEventListener('submit', async (ev) => {
        ev.preventDefault();
        const t = ($text.value || '').trim();
        if (!t) return; // フロント側の最小バリデーション
        await addTask(t);
        $text.value = '';
        await listTasks();
      });

      // 初期ロード
      listTasks().catch(() => { });
    })();
  </script>
</body>

</html>

step4 起動方法

$ powershell -File .\backend_step4_websv.ps1  -Port 8080 -Root .\www -AppBase http://localhost:8081
2025-10-26 13:17:12.358 [WEB SRV] STOP token: 4f61e5a3bc9d0278
2025-10-26 13:17:12.389 [WEB SRV] Web Server START: http://localhost:8080/ Root=.\www AppBase=http://localhost:8081 AppPaths=/app/
2025-10-26 13:17:12.405 [WEB SRV] Stop URL: http://localhost:8080/__stop?token=4f61e5a3bc9d0278
==> Open: http://localhost:8080/
==> Stop: http://localhost:8080/__stop?token=4f61e5a3bc9d0278
2025-10-26 13:17:58.386 [WEB ACC] [::1]:50860 GET / 194ms -> 200
2025-10-26 13:20:43.224 [WEB ACC] [::1]:50872 GET /__stop?token=4f61e5a3bc9d0278 26ms -> 200
2025-10-26 13:20:43.224 [WEB SRV] Web Server STOP
$
$ powershell -File .\backend_step4_apisv.ps1  -Port 8081 -AllowCorsAny
2025-10-26 13:17:24.638 [API SRV] API Server START: http://localhost:8081/ (DbBase=http://localhost:8082)
2025-10-26 13:17:24.669 [API SRV] CORS:
2025-10-26 13:17:24.669 [API SRV] Stop URL: http://localhost:8081/__stop?token=eb64d32fa91c0578
2025-10-26 13:17:58.917 [API ACC] [::1]:50861 GET /api/tasks 495ms -> 200
2025-10-26 13:19:27.735 [API ACC] [::1]:50863 OPTIONS /api/tasks 7ms -> 204
2025-10-26 13:19:27.907 [API ACC] [::1]:50863 POST /api/tasks 173ms -> 200
2025-10-26 13:19:27.978 [API ACC] [::1]:50863 GET /api/tasks 55ms -> 200
2025-10-26 13:19:33.846 [API ACC] [::1]:50863 OPTIONS /api/tasks/19 0ms -> 204
2025-10-26 13:19:33.944 [API ACC] [::1]:50863 PATCH /api/tasks/19 105ms -> 200
2025-10-26 13:19:34.022 [API ACC] [::1]:50863 GET /api/tasks 52ms -> 200
2025-10-26 13:19:36.741 [API ACC] [::1]:50863 PATCH /api/tasks/19 66ms -> 200
2025-10-26 13:19:36.803 [API ACC] [::1]:50863 GET /api/tasks 57ms -> 200
2025-10-26 13:19:40.273 [API ACC] [::1]:50866 OPTIONS /api/tasks/19 3ms -> 204
2025-10-26 13:19:40.356 [API ACC] [::1]:50866 DELETE /api/tasks/19 69ms -> 200
2025-10-26 13:19:40.411 [API ACC] [::1]:50866 GET /api/tasks 57ms -> 200
2025-10-26 13:20:29.934 [API ACC] [::1]:50871 GET /__stop?token=eb64d32fa91c0578 25ms -> 200
2025-10-26 13:20:29.950 [API SRV] API Server STOP
$

$ powershell -File .\backend_step4_dbsv.ps1  -Port 8082
2025-10-26 13:16:56.377 [DB SRV] STOP token: e01c8b3f29476d5a
2025-10-26 13:16:56.424 [DB SRV] DB Server START: http://localhost:8082/
2025-10-26 13:16:56.424 [DB SRV] CSV: .\db\tasks.csv
2025-10-26 13:16:56.424 [DB SRV] Stop URL: http://localhost:8082/__stop?token=e01c8b3f29476d5a
==> GET  http://localhost:8082/db/tasks
==> STOP http://localhost:8082/__stop?token=e01c8b3f29476d5a
2025-10-26 13:17:58.697 [DB ACC] [::1]:50862 GET /db/tasks 189ms -> 200
2025-10-26 13:19:27.860 [DB ACC] [::1]:50862 POST /db/tasks 85ms -> 200
2025-10-26 13:19:27.930 [DB ACC] [::1]:50862 GET /db/tasks 5ms -> 200
2025-10-26 13:19:33.912 [DB ACC] [::1]:50862 POST /db/toggle?id=19 36ms -> 200
2025-10-26 13:19:33.975 [DB ACC] [::1]:50862 GET /db/tasks 5ms -> 200
2025-10-26 13:19:36.694 [DB ACC] [::1]:50862 POST /db/toggle?id=19 13ms -> 200
2025-10-26 13:19:36.741 [DB ACC] [::1]:50862 GET /db/tasks 3ms -> 200
2025-10-26 13:19:40.304 [DB ACC] [::1]:50862 POST /db/delete?id=19 11ms -> 200
2025-10-26 13:19:40.357 [DB ACC] [::1]:50862 GET /db/tasks 5ms -> 200
2025-10-26 13:20:11.619 [DB ACC] [::1]:50869 GET /__stop?token=e01c8b3f29476d5a 8ms -> 200
2025-10-26 13:20:11.619 [DB SRV] DB Server STOP
$

バックエンド編まとめ(Step1〜Step4)

バックエンド編まとめ

ゴール

  • Webの全体像を掴みつつ、最小構成のサーバ分離アーキテクチャ(Web / AP / DB / API)を体験する。
  • すべて PowerShell だけで動かせる(Windows・管理者権限なし・インストール不要)。

今回の全体像

  • Webサーバ(静的ファイル配信。他サーバへ中継するゲート役)
  • APサーバ(画面を生成して返す:テンプレ+動的値=HTML)
  • DBサーバ(超簡易データストア:メモリ or ローカルJSON)
  • APIサーバ(JSON API。CORS・バリデーション・DB橋渡し)

学びのポイント

  • Step1→2→3→4と、前のステップのスクリプトや設計を流用しながら拡張していく。
  • 「車輪の再発明」を通じて、HTTP・ルーティング・バリデーション・CORS・JSONの肝を手で触る。

今回のポートの目安

  • Web: 8080
  • AP / API: 8081
  • DB: 8082

実行ログには Stop URL が出る(例:/__stop?token=xxxx)。
ブラウザで開けば正常に停止できる。


Step1:静的コンテンツのシンプルな返却

  • 目的:HTTPの基礎ログを体験(GET/404/ヘッダ/拡張子→Content-Type)。
  • 役割:Webサーバのみ(backend_step1_websv.ps1)。

動かし方(例)

powershell -File .\backend_step1_websv.ps1 -Port 8080
# → http://localhost:8080/ にアクセス

学びの観点

  • ブラウザが自動で /favicon.ico を取りに来る → 404 が出ても正常。
  • アクセスログ(メソッド、パス、処理時間、ステータス)を読む癖。

Step2:動的な画面の作成と返却(テンプレ+値)

  • 目的:WebとAPを分離して、フォーム入力→APで加工→HTMLを返す体験。
  • 役割:Web(配信・中継)+ AP(テンプレを埋めてHTML生成)。

動かし方(例)

# AP
powershell -File .\backend_step2_apsv.ps1 -Port 8081
# Web
powershell -File .\backend_step2_websv.ps1 -Port 8080 -ApBase "http://localhost:8081"
# → http://localhost:8080/ を開く(フォーム送信→Web→AP)

学びの観点

  • Webサーバは 特定URLをAPへプロキシする(RESTっぽいルーティングの入口)。
  • APは テンプレート合成でHTMLを返す(クエリ/POST値の扱い)。

Step3:DBを利用した画面の作成と返却

  • 目的:AP↔DB を分離し、超軽量DB(擬似)で永続風の体験。
  • 役割:Web(配信・中継)+ AP(画面生成)+ DB(一覧・追加・更新・削除)。

動かし方(例)

# DB
powershell -File .\backend_step3_dbsv.ps1 -Port 8082
# AP
powershell -File .\backend_step3_apsv.ps1 -Port 8081 -DbBase "http://localhost:8082"
# Web
powershell -File .\backend_step3_websv.ps1 -Port 8080 -ApBase "http://localhost:8081"
# → http://localhost:8080/

バリデーション

  • Is-AllowedText()制御文字NG200文字以内ひらがな/カタカナ/漢字/BasicLatin/一部全角記号/空白のみ許可。

Step4:APIとしてのJSON返却(フロントはfetchで利用)

  • 目的:APIサーバを新設し、JSONCORSを理解する。
  • 役割:Web(静的配信+フロント)+ API(JSON) + DB(データ)
    • フロント:index.html(fetchでJSON取得&描画)
    • API:/api/tasksGET/POST/PATCH/DELETECORS対応バリデーション(Step3流用)
    • DB:/db/* をそのまま利用

動かし方(例)

# DB
powershell -File .\backend_step4_dbsv.ps1 -Port 8082

# API(PowerShell 5.1互換・三項演算子を未使用の版)
powershell -File .\backend_step4_apisv.ps1 -Port 8081 -DbBase "http://localhost:8082" -AllowCorsFrom "http://localhost:8080"

# Web(index.html を配信)
powershell -File .\backend_step4_websv.ps1 -Port 8080
# → http://localhost:8080/ を開く

API

  • GET /api/tasks{"tasks":[...]}
  • POST /api/tasks (body: {"text":"..."} or x-www-form-urlencoded)→ 作成結果
  • PATCH /api/tasks/{id} → 完了/未完トグル
  • DELETE /api/tasks/{id}{ok:true, id:n}

CORSとエラー処理

  • preflight (OPTIONS)204で返し、Access-Control-Allow-* をセット。
  • 本体レスポンスは application/json; charset=utf-8、UTF-8固定。
  • バリデーションNG時は 400 {"error":"validation failed","reasons":[...]}

ログと停止

  • すべてのサーバで共通:
    • 起動ログServer START ...Stop URL: .../__stop?token=...
    • アクセスログ[IP] METHOD PATH 12ms -> 200
    • 停止:Stop URL をブラウザで開く(トークン一致で graceful stop)

step3のフローイメージ

参考 vscodeがない場合、tailする方法@PowerShell

while ($true) {cls ; Get-Content -Path ap_access.log -Tail 30 ; Start-Sleep -Seconds 5}

参考 外部サービス利用する場合のアーキテクチャ図— 静的ホスティング(GitHub Pages)/動的(Render + Supabase)


静的サイト配信(GitHub Pages)

ポイント

  • GitHub リポジトリにプッシュ → Pages がビルド/公開 → Pages から静的ファイル配信
  • サーバサイド実行なし:HTML/CSS/JS だけがブラウザへ届けられる

動的サイト(Render + Supabase)

ポイント

  • Render:アプリ(APサーバ)をビルド・起動して 動的処理(テンプレート、API)を実行
  • Supabase:Postgres・Auth・Storage等を提供(SDK or REST で接続)
  • ブラウザは /api/* へリクエスト → Render が DB/API と連携 → JSON/HTML で応答

使い分けメモ

  • GitHub Pages:配信は速く、運用が簡単。完全静的なので動的処理はフロントから外部APIに直接アクセスする形で設計。
  • Render + Supabase:サーバサイドのロジック(認証・料金計算・予約処理など)を Render で、データ永続化や認証基盤を Supabase に任せる構成。
    • 典型的な Web三層(ブラウザ、webサーバ/APサーバ/DB) の体験が可能。

参考(初心者向けワンポイント)

  • CDN:世界中にある配信拠点から一番近い場所で静的ファイルを返す仕組み。 ←バックエンド編というよりは、NW、インフラ関係。今回はスコープアウトの方針とする。
  • APサーバ:リクエストごとにプログラムを実行して動的に結果を返すサーバ。
  • DB:アプリが使うデータを保存・検索するためのサーバ(RDBが主流)。


Web開発勉強会 全体の振り返り

振り返り

イントロ:Webアプリとは?

Webアプリは、「ブラウザ」+「サーバ」+「ネットワーク」 が協力して動く仕組みです。

  • ブラウザ(フロントエンド):画面の表示・操作(HTML/CSS/JavaScript)
  • サーバ(バックエンド):データの保管・処理(API・DBなど)
  • ネットワーク:ブラウザとサーバをつなぐ通信路(HTTP)

ユーザーがボタンを押すと、その情報がサーバに送られ、
サーバが結果を返して画面が変わります。


フロントエンド編(ブラウザ側)

ブラウザがどんな仕組みで画面を作っているかを学びます。

HTML と CSS

  • HTML:画面の骨組み(構造)
  • CSS:画面の見た目(デザイン)

まずは、静的なページ(内容が固定されたページ)を作ってみます。

<h1>はじめまして!</h1>
<p>Webの世界へようこそ。</p>

JavaScript

  • 画面を動かすための言語
  • ボタンを押すとメッセージが出る、入力内容を確認する、などができる

ここまでで、ブラウザの中だけで動くアプリ(=フロントエンド)が完成します。


バックエンド編(サーバ側)

次に、ブラウザの外側にある「サーバ」を体験します。

Step1. Webサーバ

  • HTMLファイルをそのまま配信するサーバ
  • ブラウザで http://localhost:8080 にアクセスして画面を表示

Step2. APサーバ

  • 入力内容に応じて、動的なページを作るサーバ
  • 例:「名前」を送ると、「こんにちは〇〇さん!」と返す

Step3. DBサーバ

  • サーバがデータベース(保存場所) を使って情報を保持
  • 例:「コメントを保存」「一覧を表示」など、永続的なデータ管理

Step4. APIサーバ

  • サーバがHTMLではなくJSON形式 のデータを返す
  • フロントエンドがそのデータを使って画面を作る
  • これが現代のWebアプリの人気スタイル

おまけ:なぜ関係ないサイト、別のWebサーバからのJavaScriptが実行されるの?

オリジンの考え方、必要性

回答

ブラウザは「Webサーバが指示したとおりに表示と実行を行う」仕組みだからです。
ブラウザ自身が勝手に外部サイトのスクリプトを実行するわけではなく、
あなたが開いたWebページの中に、外部スクリプトを読み込む命令が書かれているから なのです。


1. そもそもブラウザの役割

ブラウザは

「HTMLを受け取り、そこに書かれた命令(画像を表示せよ、CSSを適用せよ、JSを実行せよ)をそのまま実行する」

という仕組みで動いています。
つまり、Webサーバが「このJavaScriptを実行してください」と書いて送れば、
ブラウザはそれを忠実に実行します。


2. HTMLの仕組みが「どこからでも読み込める」ようになっている。HTMLは「宣言的構造」だから

HTMLは「命令」ではなく「宣言」です。
「どう動くか」ではなく「何を読み込むか」をブラウザに伝えるだけ。
実際の動作はブラウザが解釈して決めます。

<img src="…"> → 画像を取ってきて表示せよ
<link rel="stylesheet" href="…"> → CSSを取ってきて適用せよ
<script src="…"> → JSを取ってきて実行せよ

この「src 属性に指定されたURL」が外部でも構わない仕様だから、
他サーバのJSも読み込めてしまうのです。
ブラウザは「サーバがそう指定したなら、必要なファイルを取りに行って実行/表示する」という動きをします。


3. なぜそういう仕様なのか?(歴史的・設計的な理由)

Webはもともと「分業と共有の文化」で作られています。

  • 画像は画像サーバ(CDN)
  • CSSやJSは共有ライブラリ(例:jQuery CDN)
  • フォントや広告、解析タグなどは外部サービス提供

つまり1つのWebページは、複数のサーバの協力で成り立っているのです。
これを可能にするため、HTMLは最初から「外部リソースの読み込み」を許可する設計になっています。

ブラウザは「どこから取ってきたJavaScriptか」で実行の可否を判断しません。
外部JSでも、受け取ったHTMLが指示したなら実行します。

なぜならHTMLの作者(サーバ)がそれを意図したと見なされるからです。

「あなた(ページ提供者)がこのJSを使いたいんですね。分かりました。」

という設計思想です。


4. では何が危険なのか?

ブラウザは、ページの作者(=Webサーバ)を信じて命令を実行します。
しかし、そのページが以下のような状態だと危険です。

状況 危険内容
外部スクリプトが改ざんされる 改ざんされたJSがユーザーのブラウザで実行され、情報が盗まれる。
信頼できないサイトのJSを読み込む そのJSが悪意を持ってCookieや入力内容を盗む可能性がある。
CDNや外部サービスが障害/乗っ取り ページ全体が影響を受ける。

5. ブラウザは安全をどう保っているの?

ブラウザには「同一オリジンポリシー(SOP)」というルールがあります。
これは、

「別のサイトの情報を直接読むことはできない」

という制限です。

つまり、

  • 外部のJSは実行できる(便利さのため)
  • でも、他のサイトのデータやCookieは勝手に読めない(安全のため)

というバランスを取っています。


6. Web側でできる安全対策

対策 目的
CSP(Content Security Policy) 外部スクリプトの読み込み先を制限する
SRI(Subresource Integrity) 外部スクリプトの改ざんを検出する
HTTPS 通信経路での改ざんを防ぐ
署名付きCDNやライブラリの固定バージョン 信頼できる配布元から取得する

例)CSPで読み込み元を制限する:

Content-Security-Policy: script-src 'self' https://cdn.jsdelivr.net https://apis.google.com;

例)SRIで改ざんを検知する:

<script src="https://cdn.example.com/lib.js"
        integrity="sha384-xxxxx..." crossorigin="anonymous"></script>

7. オリジン関係のまとめ

ブラウザは「ページの作者(=Webサーバ)を信じる」設計になっている。
そのサーバが外部JSを読み込むよう指定すれば、ブラウザはそれを実行する。

安全を守るためには、

  • どこから読み込むか(CSP)
  • 内容が改ざんされていないか(SRI)
  • 通信が安全か(HTTPS)
    をサーバ側でコントロールする必要がある。

一言まとめ

「ブラウザは命令に従うだけ。何を命令するかはWebサーバ次第。

だからこそ、Webサーバ側で安全な命令(CSP/SRI設定)を出す ことが重要です。


おまけ:フレームワーク・ライブラリ

開発の目的に対して、
面倒な繰り返し作業や、
誰でも同じ実装になるものなどがあります。
仕組みや基礎を理解する目的では体験しておくべきですが、
エンタープライズの開発では効率化、保守性、セキュリティ対策として、
多くの開発者が共通の部品(フレームワーク・ライブラリ)を使っています。

  • フロントエンド:React / Vue / Angular など
  • バックエンド:Spring Boot / Ruby on Rails / Express など

これらは「車のエンジンやタイヤ」を既製品に置き換えるようなもの。
基礎を理解した上で使うと、とても効率的になります。


おまけ:ローカルストレージ紹介

ここまでは「サーバにデータを保存」する話でしたが、
実は バックエンドがなくても、ブラウザだけでデータを永続化 する方法があります。

それが LocalStorage(ローカルストレージ) です。


仕組み

  • ブラウザの中にある「小さなデータベース的なもの」
  • 名前(キー)と内容(値)をセットで保存します
  • 保存したデータはページを閉じても残る(ただし同じブラウザ・同じサイト内限定)

使い方のイメージ

// 保存
localStorage.setItem("name", "二郎");

// 取り出し
const user = localStorage.getItem("name");
console.log(user); // → 二郎

// 削除
localStorage.removeItem("name");

web開発で出会うデータ保存、データ永続層


実例:ToDoアプリ

フロントエンド編で使ったHTML/CSS/JavaScriptを改修して、
「ローカルストレージにタスクを保存」することができます。

  1. タスクを入力して追加
  2. ページをリロードしても、前のタスクが残る(=保存された)
  3. ブラウザの「開発者ツール → Application → LocalStorage」で中身を確認できる

これにより、「バックエンドがなくても永続化できる」体験ができます。

デモ

ソース


注意点

項目 内容
容量 数MB程度(ブラウザによる制限あり)
共有 同じブラウザ・同じサイトでしか共有できない
安全性 JavaScriptから誰でも読める(パスワードなどはNG)
速度 同期処理のため、大量データには不向き
削除 ユーザー操作や設定で削除されることもある

LocalStorageとバックエンドの違い

観点 LocalStorage サーバDB
保存場所 ブラウザ内 サーバ側
利用範囲 自分のPCのブラウザのみ すべてのユーザーで共有可能
セキュリティ 弱い(平文) 強い(認証や暗号化あり)
通信 不要(オフラインOK) 必要(ネットワーク経由)

まとめ

  • LocalStorage は「自分のブラウザに保存」する軽量な仕組み
  • 小さなアプリや一時保存に便利
  • ただし、本格的な共有や安全な保存にはバックエンドが必要

「フロントエンドだけでもこんなことができる!」という実体験を通して、
サーバの必要性が自然と理解できる構成になっています。


参考:デモ構成

  • HTML, CSS, JavaScript の3ファイルだけで動作
  • バックエンド不要(ローカルで完結)
  • PowerShell の Web サーバで簡単に配信可能
    powershell -File .\backend_step1_websv.ps1 -Open
    
  • URL例:http://localhost:8080/index.html
  • ファイルをブラウザで開く方法でも利用可能

一言でまとめると

「サーバがなくても動く」ことを体験して、
 「なぜサーバが必要か」を実感するステップです。


参考 現代でのwebアプリ、webシステムとは

歴史的には画面があるものがwebシステムでしたが、
現在では以下のような場合もあります。

HTTP+URL+HTML
↓
HTTP+URL+HTML、XML、JSONなど

HTTP通信(URLでアクセス) を使う仕組みをまとめて「Webシステム」と呼べます。
つまり、画面がある/ない、社内/外部は関係ありません。
利用者が人(ブラウザ)でもプログラム(API呼び出し)でもOK。webAPI。

  • 社内LANで動く在庫管理アプリ
  • スマホアプリが呼び出すAPI
  • 部署間連携用のデータAPI

これらもすべてHTTPで通信していれば、Webシステムです。
HTTPはステートレスな文書運搬プロトコルです。


参考 httpがステートレスということの説明


参考 手軽にローカル開発環境でhttpsサーバを用意する


参考 http通信を手打ちしてみる


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?