見積・工程・請求をまとめて管理する業務アプリを、サーバーなし・外部送信なしで作りました。zip を展開して index.html をブラウザで開くだけで動き、データはそのパソコンのブラウザ(localStorage)にだけ保存します。
サーバーがないので、データを守る仕組みは全部ブラウザの中で用意する必要があります。この記事では、作るなかで気をつけた5つのことを、実際のコードを短く引用してまとめます。
- 壊れたJSONを拒否して、今のデータに触らない
- 保存データが壊れていたら、消さずに別名で残す
- CSP の
connect-src 'none'で外部通信を止める - 不具合報告用の情報に、データの中身を入れない
-
file://で開くときの注意(Safari など)
確認した環境:Node.js 24.20.0、Google Chrome 155(headless、file:// で開いて確認)。
1. 壊れたJSONを拒否して、今のデータに触らない
バックアップは「状態をまるごと JSON にしてダウンロードする」だけです。目印として、アプリ名と形式の版を外側に付けます。
var APP = 'kensetsu-studio', FORMAT = 1, MAX_CHARS = 20 * 1024 * 1024;
function makeBackup(state, nowIso) {
return JSON.stringify({ app: APP, format: FORMAT, version: '1.0', savedAt: nowIso || '', data: state }, null, 1);
}
難しいのは戻すほうです。利用者が選ぶファイルは、途中で切れていたり、別のアプリの JSON だったりします。そこで、戻す処理は 「読んで確かめる関数」と「置きかえる処理」を分け、確かめる関数は結果を返すだけにしました。
function parseJson(text) {
if (typeof text !== 'string') return { ok: false, error: 'ファイルを読めませんでした' };
if (text.length > MAX_CHARS) return { ok: false, error: 'ファイルが大きすぎます(20MBまで)' };
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
try { return { ok: true, value: JSON.parse(text) }; }
catch (e) { return { ok: false, error: 'JSONファイルとして読めません(壊れているか、別のファイルです)' }; }
}
function parseBackup(text) {
var j = parseJson(text); if (!j.ok) return j;
var o = j.value;
if (!isObj(o) || o.app !== APP) return { ok: false, error: '建設スタジオのバックアップファイルではありません' };
if (o.format !== FORMAT) return { ok: false, error: 'バックアップの形式(' + String(o.format) + ')に対応していません' };
var r = checkState(o.data);
if (!r.ok) return r;
return { ok: true, data: r.data, savedAt: typeof o.savedAt === 'string' ? o.savedAt : '' };
}
checkState では、一覧が配列か、各要素がオブジェクトか、id が必要な一覧に id があるか、などを1つずつ見ます。最後に JSON.parse(JSON.stringify(o)) で 新しいオブジェクトを作って返す ので、呼び出し側が受け取ったデータをいじっても、元のオブジェクトは変わりません。
画面側は、ok:false なら置きかえる前に止めます。
var r = BK.parseBackup(text);
if (!r.ok) { window.alert('このファイルからは戻せませんでした。\n理由:' + r.error + '\n\n今のデータはそのままです(何も変えていません)。'); return false; }
置きかえる前には、件数を見せて確認を取り、今のデータを -before-restore という別のキーにも残します(容量が足りないときは残さず進みます)。
Node で、いくつかのファイルを読ませてみた結果です。
const BK = require('./assets/backup.js');
for (const x of ['', '{', 'null', '[]', text.slice(0, 40)]) console.log(BK.parseBackup(x));
| 読ませたもの | 結果(error) |
|---|---|
空の文字列・{・途中で切れたバックアップ |
JSONファイルとして読めません(壊れているか、別のファイルです) |
null・[]・別アプリの JSON |
建設スタジオのバックアップファイルではありません |
| id のない現場が入ったバックアップ | 「sites」の1件目に id がありません |
| 先頭に BOM が付いたバックアップ |
ok: true(読める) |
テスト(test.js)では、壊れたファイル17種類について「ok:false で、error が空でなく、data を含まない」ことと、全部読ませたあとも元のデータの JSON.stringify が変わらないことを確かめています。
badFiles.forEach(([t, m]) => { const r = BK.parseBackup(t); eq(r.ok === false && typeof r.error === 'string' && r.error.length > 0 && !('data' in r), true, '拒否:' + m); });
const before = JSON.stringify(mine); badFiles.forEach(([t]) => BK.parseBackup(t)); eq(JSON.stringify(mine), before, '拒否しても元のデータは変わらない');
2. 保存データが壊れていたら、消さずに別名で残す
localStorage の中身も、壊れることがあります(書き込みの途中で止まった、手で触った、など)。読めないからといって空のデータで上書きすると、直せたかもしれないデータが消えます。
そこで、読めないときは元の文字列を別のキーに移してから、空の状態で始めるようにしました。
function load() {
var raw = null;
try { raw = localStorage.getItem(KEY); } catch (e) { storageOk = false; }
if (raw) {
var r = BK.parseState(raw);
if (r.ok) return r.data;
// 読めないデータは消さずに別の名前で残してから、空で始める
try { localStorage.setItem(KEY_BROKEN, raw); brokenKept = true; } catch (e) { /* 残せなければ上書きしない */ storageOk = false; }
}
return DATA.empty(TODAY);
}
brokenKept が立っていたら、画面の上に「前回のデータが読めなかったため、空の状態で開きました。前回のデータは消さずに残してあります」と出します。
この load() を、Map で作った偽の localStorage で動かすと、こうなりました。
const store = new Map([['kensetsu-studio-v1', '{"v":1,"company":']]);
const localStorage = { getItem: k => store.has(k) ? store.get(k) : null, setItem: (k, v) => store.set(k, String(v)) };
// …上の load() を実行
console.log([...store]);
// [ [ 'kensetsu-studio-v1', '{"v":1,"company":' ],
// [ 'kensetsu-studio-v1-broken', '{"v":1,"company":' ] ]
壊れた文字列が -broken のキーにそのまま残りました。本物の Chrome でも、localStorage に壊れた文字列を入れて開き直し、-broken に残ることを確かめています。
また、localStorage の読み書きは すべて try の中 に入れました。プライベートモードや容量がいっぱいのときは例外になることがあるので、そのときは storageOk = false にして「保存できません。すぐにバックアップを保存してください」と表示します。
3. CSP の connect-src 'none' で外部通信を止める
「外部に送信しない」と書くだけでなく、ブラウザの仕組みで止めておきたかったので、index.html に CSP を入れました。
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; base-uri 'none'; form-action 'none'; object-src 'none'">
connect-src 'none' で fetch・XMLHttpRequest・WebSocket などの通信先がなくなり、form-action 'none' でフォームの送信先もなくなります。script-src 'self' なので、外のスクリプトもインラインのスクリプトも動きません。
本当に止まるかを、小さなページで確かめました。CSP を入れたページから fetch('https://example.com/') を呼び、結果をページに書き出して、headless Chrome の --dump-dom で見ます。
// csp.js(script-src 'self' なので別ファイルにする)
document.addEventListener('securitypolicyviolation', e => o.textContent += ' | violation:' + e.violatedDirective);
fetch('https://example.com/').then(() => o.textContent += ' | fetch:ok', e => o.textContent += ' | fetch:' + e.name);
結果は fetch:TypeError | violation:connect-src で、通信は止まり、違反のイベントが出ました。
あわせて、テストでは次の2つも見ています。
-
index.htmlの CSP にdefault-src 'none'・connect-src 'none'・form-action 'none'があること - アプリのすべての
.jsにfetch(・XMLHttpRequest・WebSocket・sendBeaconなどの文字がないこと
さらに Chrome で一通り操作したときのリクエストを全部記録し、file:・data:・blob: 以外が0件であることを確かめています(今回は67件中、外部は0件)。
4. 不具合報告用の情報に、データの中身を入れない
サーバーがないので、エラーのログを集めることはできません。困ったときに利用者がメッセージに貼れるよう、「報告用の情報をコピー」ボタンを付けました。
ここで気をつけたのは、入力したデータ(会社名・取引先・金額)を絶対に混ぜないことです。報告文を作る関数は、使う項目だけを名前で取り出し、ほかは見ません。
function makeReport(info) {
info = info || {};
return [
'建設スタジオ 不具合の報告用の情報',
'版:v' + clip(info.version, 20),
'ブラウザ:' + clip(info.ua, 300),
'画面:' + clip(info.view, 30),
'最後に出たエラー:' + errText(info.error),
'日時:' + clip(info.at, 20),
'(会社名・取引先・金額など、入力したデータは入っていません)'
].join('\n');
}
もう1つの落とし穴が、エラーの ファイルのパス です。file:// で開いていると、エラーの filename は file:///Users/<ユーザー名>/Downloads/... のようになり、ユーザー名が入ってしまいます。そこで、ファイル名だけを残すようにしました。
function baseName(u) { u = String(u || ''); var m = /([^\/\\?#]+)(?:[?#].*)?$/.exec(u); return m ? m[1] : ''; }
わざと会社名などを混ぜた情報を渡して試した結果です。
RP.makeReport({ ...state, version: '1.0', ua: 'TestUA', view: '設定', at: '2026-10-25 14:30',
error: { msg: 'x is not defined', file: 'file:///Users/tanaka/Downloads/app/assets/app.js?v=1', line: 12, at: '2026-10-25 14:29' } });
建設スタジオ 不具合の報告用の情報
版:v1.0
ブラウザ:TestUA
画面:設定
最後に出たエラー:x is not defined(app.js 12行)・2026-10-25 14:29
日時:2026-10-25 14:30
(会社名・取引先・金額など、入力したデータは入っていません)
会社名もフォルダの場所も出ていません。Windows 形式の C:\Users\sato\Desktop\app.js も app.js になりました。
テストでは、見本データの会社名・住所・電話・振込先・現場名・取引先・人名を集めて、報告文に1つも入っていないこと、¥ や「○円」が入っていないことを確かめています。画面側の reportText() が保存データの変数を読んでいないことも、関数の中身を正規表現で見て確かめています。
もう1つ、エラーを拾う report.js は いちばん先に読み込む ようにしました。あとから読むと、それより前のスクリプトで起きたエラーを記録できないためです。
5. file:// で開くときの注意(Safari など)
index.html をダブルクリックして開く作りには、いくつか注意があります。
- ダブルクリックだと、パソコンの標準のブラウザで開く:Mac なら Safari で開くことがあります。今回は Safari での確認ができていないので、説明書では右クリック →「このアプリケーションで開く」→「Google Chrome」の手順を案内しました。
- localStorage はブラウザごとに別:Safari で入力したデータは、Chrome で開くと見えません。ブラウザを移るときは、バックアップの JSON を保存して、移った先で「バックアップから戻す」を使ってもらいます。
-
file:の origin の扱いはブラウザによって違う:URL の仕様では、file:の origin の扱いはブラウザに任されています。localStorage がfile://でどう保存されるかも、ブラウザごとに確かめる必要があります。Chrome 155 では、file://で開いた画面で localStorage に書き込めること、閉じて開き直してもデータが残ることを確かめました。 - ブラウザの「履歴・サイトデータを消す」で、入力も消える:設定画面に、週に1回と、たくさん入力した日の終わりにバックアップを保存するよう書いています。最後にバックアップした日時もサイドバーに出します。
まとめ
- 戻す処理は「確かめる関数(結果を返すだけ)」と「置きかえる処理」に分け、確かめに通らなければ今のデータに触らない
- localStorage の中身が読めなくても消さず、別のキーに残してから空で始める
- CSP の
connect-src 'none'とform-action 'none'で、ブラウザの仕組みとして外部送信を止める - 報告用の情報は使う項目だけを取り出し、エラーのパスはファイル名だけにする
-
file://で開く作りは、ブラウザごとに保存のされ方が違うので、確かめたブラウザを案内する
この仕組みで、建設業向けの見積・工程・請求をまとめて管理するアプリを作りました。
※この記事は、AI(Claude)を活用して作成し、記事中のコードと結果は Node と Chrome で実際に動かして確認しています。