30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Deno や curl で Cookie を扱う方法

Cookie そのものについての説明は、別途記事を書いているのでそちらを参照。

numb86-tech.hatenablog.com

numb86-tech.hatenablog.com

動作確認に使った実行環境やツールのバージョンは以下の通り。

  • Google Chrome 85.0.4183.121
  • Deno 1.4.4
  • curl 7.54.0

Deno

まずはサーバから Cookie をセットする方法。

標準モジュールが提供しているsetCookieを使うと、レスポンスヘッダに簡単にset-cookieフィールドを追加できる。
Cookie を取り出すためのgetCookiesも提供されている。

import {
  listenAndServe,
  ServerRequest,
  setCookie,
  getCookies,
} from "https://deno.land/std@0.74.0/http/mod.ts";

const getRouting = async (req: ServerRequest) => {
  switch (req.url) {
    case "/cookie": {
      const response = {
        status: 200,
        headers: new Headers({
          "content-type": "text/plain",
        }),
        body: "Set cookies.\n",
      };
      setCookie(response, { name: "Space", value: "Cat" });
      setCookie(response, { name: "my-cookie", value: "abc" });
      req.respond(response);
      break;
    }
    default:
      req.respond({
        status: 404,
        headers: new Headers({
          "content-type": "text/plain",
        }),
        body: "Not found\n",
      });
      break;
  }
};

listenAndServe({ port: 8080 }, async (req: ServerRequest) => {
  console.log(req.headers.get('cookie'));
  console.log(getCookies(req));

  switch (req.method) {
    case "GET":
      getRouting(req);
      break;
    default:
      req.respond({
        status: 405,
      });
  }
});

サーバを起動してhttp://localhost:8080/cookieにアクセスすると、Cookie が付与される。

初回アクセス時は Cookie が存在しないので、以下の結果になる。

console.log(req.headers.get('cookie')); // null
console.log(getCookies(req)); // {}

2 回目以降はリクエストヘッダにCookie: Space=Cat; my-cookie=abcが含まれているので、以下の結果になる。

console.log(req.headers.get('cookie')); // Space=Cat; my-cookie=abc
console.log(getCookies(req)); // { Space: "Cat", "my-cookie": "abc" }

curl

curl で Cookie を送信するには、-bオプションを使う。
以下を実行すると、リクエストヘッダにcookie: name=value; a=bが含まれる。

$ curl -b "name=value; a=b" http://localhost:8080/

-cオプションを使うと、サーバから送られてきた Cookie の内容をファイルに保存できる。

$ curl -c cookie.txt http://localhost:8080/cookie

上記のコマンドを実行すると、以下の内容のcookie.txtが作られる。

# Netscape HTTP Cookie File
# https://curl.haxx.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

localhost   FALSE   /   FALSE   0   Space   Cat
localhost   FALSE   /   FALSE   0   my-cookie   abc

先程紹介した-bオプションには、この形式のファイルを渡すこともできる。
以下のコマンドを実行すると、リクエストヘッダにcookie: Space=Cat; my-cookie=abcが含まれる。

$ curl -b cookie.txt http://localhost:8080/cookie

参考資料

Escodegen でサロゲートペアを扱う

Escodegen は AST からソースコードを生成する npm パッケージ。
互換性のある AST を渡せば自動的にソースコードを生成してくれるのであまり悩むことはないのだが、絵文字などのサロゲートペアを扱うときには注意が必要になる。
Power Assert Deno の開発時に躓いたので、書き残しておく。

動作確認は Node.js のv14.7.0で行っており、利用しているライブラリのバージョンは以下の通り。

  • esprima@4.0.1
  • estraverse@5.2.0
  • escodegen@2.0.0

それぞれのライブラリの基本的な使い方は、以下を参照。

numb86-tech.hatenablog.com

どのような問題が起きるのか

以下のコードは、sourceを Esprima で AST に変換し、Escodegen でその AST をソースコードに変換している。

const esprima = require('esprima');
const escodegen = require('escodegen');

const source = `const x = 'abc';`;

const ast = esprima.parseScript(source);

console.log(escodegen.generate(ast)); // const x = 'abc';

AST に対して何の操作も行っていないので、元の文字列と同じconst x = 'abc';が生成される。

だが、sourceにサロゲートペアが含まれていると、上手くいかなくなる。

const esprima = require('esprima');
const escodegen = require('escodegen');

const source = `const x = '🦕';`;

const ast = esprima.parseScript(source);

console.log(escodegen.generate(ast)); // const x = '\uD83E\uDD95';

🦕\uD83E\uDD95になってしまっている。

AST の時点では🦕のままなので、Escodegen がコードを生成する際に\uD83E\uDD95に置き換わってしまっていることが分かる。

const esprima = require('esprima');
const escodegen = require('escodegen');

const source = `const x = '🦕';`;

const ast = esprima.parseScript(source);

console.log(ast.body[0].declarations[0].init); // Literal { type: 'Literal', value: '🦕', raw: "'🦕'" }
console.log(escodegen.generate(ast)); // const x = '\uD83E\uDD95';

🦕の Code Point は1f995であり、それを UTF-16 で表現したものが\uD83E\uDD95なので、意味するものが変わってしまっているわけではない。

console.log('🦕'.codePointAt(0).toString(16)); // 1f995
console.log('\uD83E\uDD95'); // 🦕

とはいえ、文字列としては明らかに別物なので、これでは困る。

ちなみに、テンプレートリテラルの場合は🦕がそのまま維持される。シングルクォーテーションかダブルクォーテーションで括ったときに問題となる。

const esprima = require('esprima');
const escodegen = require('escodegen');

console.log(escodegen.generate(esprima.parseScript('const x = "🦕";'))); // const x = '\uD83E\uDD95';
console.log(escodegen.generate(esprima.parseScript("const x = '🦕';"))); // const x = '\uD83E\uDD95';
console.log(escodegen.generate(esprima.parseScript('const x = `🦕`;'))); // const x = `🦕`;

verbatim オプション

調べてみると、verbatimというオプションの値をrawにすればよいことが分かった。

github.com

早速試してみる。

const esprima = require('esprima');
const escodegen = require('escodegen');

const source = `const x = '🦕';`;

const ast = esprima.parseScript(source);

console.log(escodegen.generate(ast)); // const x = '\uD83E\uDD95';
console.log(escodegen.generate(ast, {verbatim: 'raw'})); // const x = ('🦕');

🦕のまま、コードを生成することができた。
だが今度は、不要な()が付与されてしまっている。JavaScript のコードとしては意味は変わらないのだが、文字列としてはやはり別物である。
それに、オブジェクトのキーとして文字列を使っていた場合に、JavaScript として有効ではないコードが生成されてしまう。

const esprima = require('esprima');
const escodegen = require('escodegen');

const source = `const x = {"some-key": 1}`;

const ast = esprima.parseScript(source);

console.log(escodegen.generate(ast, {verbatim: 'raw'})); // const x = { ("some-key"): (1) };

const x = { ("some-key"): (1) };を実行するとシンタックスエラーになる。

x-verbatim-property

この()を何とかできないのか、そもそもverbatimにはrawしか渡せないのだろうかと調べてみると、以下の Issue が見つかった。

github.com

これによると、x-verbatim-propertyを使うと上手くいくらしい。

ドキュメントを改めて読んでみると、確かに記述があった。

github.com

この内容に沿って書いたのが、以下のコード。

const esprima = require('esprima');
const escodegen = require('escodegen');
const estraverse = require('estraverse');

const source = `const x = '🦕';`;

const ast = esprima.parseScript(source);

estraverse.replace(ast, {
  enter(node) {
    if (node.type === 'Literal' && typeof node.value === 'string') {
      return {
        ...node,
        'x-verbatim-property': {
          content: node.value,
          precedence: escodegen.Precedence.Primary,
        },
      };
    }
    return undefined;
  },
});

console.log(escodegen.generate(ast, {verbatim: 'x-verbatim-property'})); // const x = 🦕;

こうすると🦕は維持されるのだが、今度は'が失われてしまっている。文字列がクォーテーションで囲まれていないので、const x = 🦕;もやはり、シンタックスエラーになる。

この問題に対応したのが、以下のコード。

const esprima = require('esprima');
const escodegen = require('escodegen');
const estraverse = require('estraverse');

const source = `const x = '🦕';`;

const ast = esprima.parseScript(source);

estraverse.replace(ast, {
  enter(node) {
    if (node.type === 'Literal' && typeof node.value === 'string') {
      const wrapByQuotation = (value) => {
        if (value.includes("'")) {
          return `"${value}"`;
        }
        return `'${value}'`;
      };
      return {
        ...node,
        'x-verbatim-property': {
          content: wrapByQuotation(node.value),
          precedence: escodegen.Precedence.Primary,
        },
      };
    }
    return undefined;
  },
});

console.log(escodegen.generate(ast, {verbatim: 'x-verbatim-property'})); // const x = '🦕';

無事にconst x = '🦕';を復元できた。