@m1kapp/kit 0.0.48 → 0.0.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -555,6 +555,9 @@ npx m1kkit pwa --bg=#0f0f1a # 앱 첫 화면 색과 맞춘다(다르면 열릴
555
555
  Vite 는 `index.html` `<head>` 에 태그가 들어가고(다시 돌리면 교체), Next 는 출력된 태그를 layout 에 붙이세요.
556
556
  서비스워커는 localhost 에선 등록하지 않습니다 — 확인은 배포 뒤에.
557
557
 
558
+ 갱신: 온라인으로 열면 매번 새 배포(페이지는 네트워크 먼저). 켜 둔 채 다시 켜는 앱은 돌아올 때 30분에 한 번 새 배포를 확인해 새로고침합니다.
559
+ `sw.js` 는 배포마다 바꿀 필요가 없고, kit 을 올린 뒤 `npx m1kkit pwa` 를 다시 돌리면 그 판의 서비스워커로 바뀝니다.
560
+
558
561
  ### 폰트 & 이모지
559
562
 
560
563
  ```tsx
package/bin/pwa.mjs CHANGED
@@ -44,22 +44,38 @@ function write(rel, data) {
44
44
  /* ─── 서비스워커 ─── */
45
45
  write("sw.js", `// m1kkit pwa 가 만든 파일 — 오프라인에서도 앱이 열리게 한다. 다시 만들려면 npx m1kkit pwa
46
46
  // 페이지: 네트워크 먼저, 3초 안에 안 오거나 끊겨 있으면 저장본(여행지 로밍처럼 느린 망에서도 바로 뜬다).
47
+ // 그래서 새 배포는 온라인이면 다음 실행에 바로 보인다. 이 파일은 배포마다 바꿀 필요가 없다.
47
48
  // JS·CSS·폰트·이미지: 저장본을 바로 주고 뒤에서 새로 받아 둔다. API(fetch) 요청은 건드리지 않는다.
48
- // ponytail: 캐시 이름이 하나라 배포마다 옛 해시 파일이 조금씩 쌓인다 — 커지면 activate 에서 정리
49
+ // ponytail: 캐시 이름이 하나라 배포마다 옛 해시 파일이 조금씩 쌓인다 — 커지면 savePage 에서 안 쓰는 것 정리
49
50
  const CACHE = "m1kkit-pwa";
50
51
  const ASSET = ["script", "style", "font", "image", "manifest"];
51
52
 
53
+ // SPA 호스팅(Vercel rewrites 등)은 없는 파일에도 index.html 을 200 으로 준다.
54
+ // 그걸 JS 자리에 담으면 다음 오프라인 실행이 하얗게 뜬다 — 페이지가 아닌데 HTML 이면 안 담는다.
55
+ const keepable = (req, res) => res.type === "opaque" ||
56
+ (res.ok && (req.mode === "navigate" || !(res.headers.get("content-type") ?? "").includes("text/html")));
57
+
58
+ // 페이지는 그 페이지가 부르는 JS·CSS 를 다 담은 뒤에 담는다 — 저장된 페이지가 없는 파일을 가리키는 순간이 없게.
59
+ // 하나라도 못 받으면 페이지는 옛 판을 그대로 둔다(옛 판의 파일은 남아 있다).
60
+ async function savePage(c, key, res) {
61
+ const html = await res.clone().text();
62
+ const own = [...html.matchAll(/(?:src|href)="(\\/[^"/][^"]*)"/g)].map((m) => new Request(m[1]));
63
+ // CDN 폰트 CSS 도(Pretendard·Tossface 같은) — 빠지면 오프라인에서 글꼴·국기 이모지가 바뀐다. 실패해도 페이지는 담는다
64
+ const cdn = [...html.matchAll(/href="(https:\\/\\/[^"]+\\.css)"/g)].map((m) => new Request(m[1], { mode: "no-cors" }));
65
+ const got = (req) => c.match(req, { ignoreVary: true }).then(async (hit) => {
66
+ if (hit) return true;
67
+ const r = await fetch(req).catch(() => null); // add() 는 opaque 를 거절해서 직접 담는다
68
+ return !!r && keepable(req, r) && c.put(req, r).then(() => true);
69
+ });
70
+ const ok = await Promise.all(own.map(got));
71
+ await Promise.all(cdn.map(got));
72
+ if (ok.every(Boolean)) await c.put(key, res);
73
+ }
74
+
52
75
  self.addEventListener("install", (e) => {
53
76
  e.waitUntil((async () => {
54
- const c = await caches.open(CACHE);
55
77
  const res = await fetch("/", { cache: "no-store" });
56
- await c.put("/", res.clone());
57
- // 첫 방문에 이미 받은 JS·CSS 도 담아 둔다 — 두 번째 실행이 바로 오프라인이어도 뜨게
58
- const html = await res.text();
59
- const urls = [...html.matchAll(/(?:src|href)="(\\/[^"/][^"]*)"/g)].map((m) => m[1]);
60
- // CDN 폰트 CSS 도(Pretendard·Tossface 같은) — 이게 빠지면 오프라인에서 글꼴·국기 이모지가 바뀐다
61
- const css = [...html.matchAll(/href="(https:\\/\\/[^"]+\\.css)"/g)].map((m) => new Request(m[1], { mode: "no-cors" }));
62
- await Promise.all([...urls, ...css].map((u) => fetch(u).then((r) => (r.ok || r.type === "opaque") && c.put(u, r)).catch(() => {}))); // add() 는 opaque 를 거절한다
78
+ if (res.ok) await savePage(await caches.open(CACHE), "/", res);
63
79
  await self.skipWaiting();
64
80
  })());
65
81
  });
@@ -69,15 +85,19 @@ self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim()));
69
85
  self.addEventListener("fetch", (e) => {
70
86
  const r = e.request;
71
87
  if (r.method !== "GET" || !r.url.startsWith("http")) return;
72
- if (r.mode === "navigate") return e.respondWith(page(r));
88
+ if (r.mode === "navigate") {
89
+ const net = fetch(r);
90
+ // 복사는 응답이 화면으로 가기 전에, 같은 차례에서 — 늦으면 body already used
91
+ e.waitUntil(net.then((res) => { if (!res.ok) return; const copy = res.clone(); return caches.open(CACHE).then((c) => savePage(c, r, copy)); }).catch(() => {}));
92
+ return e.respondWith(page(r, net));
93
+ }
73
94
  const same = new URL(r.url).origin === location.origin;
74
95
  if (same ? ASSET.includes(r.destination) : r.destination === "style" || r.destination === "font") e.respondWith(stale(r));
75
96
  });
76
97
 
77
- async function page(r) {
98
+ async function page(r, net) {
78
99
  const c = await caches.open(CACHE);
79
100
  const saved = () => c.match(r, { ignoreSearch: true, ignoreVary: true }).then((m) => m ?? c.match("/"));
80
- const net = fetch(r).then((res) => { if (res.ok) c.put(r, res.clone()); return res; });
81
101
  const slow = new Promise((ok) => setTimeout(ok, 3000)).then(async () => (await saved()) ?? net);
82
102
  try { return await Promise.race([net, slow]); } catch { return (await saved()) ?? Response.error(); }
83
103
  }
@@ -85,7 +105,7 @@ async function page(r) {
85
105
  async function stale(r) {
86
106
  const c = await caches.open(CACHE);
87
107
  const hit = await c.match(r, { ignoreVary: true }); // Vary: Origin 서버에서 저장본을 못 찾던 것
88
- const net = fetch(r).then((res) => { if (res.ok || res.type === "opaque") c.put(r, res.clone()); return res; });
108
+ const net = fetch(r).then((res) => { if (keepable(r, res)) c.put(r, res.clone()); return res; });
89
109
  if (!hit) return net;
90
110
  net.catch(() => {});
91
111
  return hit;
@@ -139,6 +159,7 @@ if (fs.existsSync(manifestPath)) {
139
159
  /* ─── <head> 태그 ─── */
140
160
  // apple-mobile-web-app-capable 이 없으면 iOS 가 스플래시를 아예 안 쓴다.
141
161
  // 서비스워커는 localhost 에선 안 건다 — 개발 중 HMR 이 저장본에 막히지 않게.
162
+ // 등록 스크립트는 켜 둔 앱이 돌아올 때 새 배포를 확인한다 — 내 JS 파일 이름이 새 HTML 에 없으면 새로고침.
142
163
  const head = [
143
164
  `<!-- m1kkit:pwa -->`,
144
165
  `<meta name="apple-mobile-web-app-capable" content="yes" />`,
@@ -147,7 +168,20 @@ const head = [
147
168
  ...(title ? [`<meta name="apple-mobile-web-app-title" content="${title}" />`] : []),
148
169
  `<meta name="theme-color" content="${bg}" />`,
149
170
  ...links,
150
- `<script>if ("serviceWorker" in navigator && !/^(localhost|127\\.|\\[::1\\])/.test(location.hostname)) navigator.serviceWorker.register("/sw.js");</script>`,
171
+ `<script>
172
+ if ("serviceWorker" in navigator && !/^(localhost|127\\.|\\[::1\\])/.test(location.hostname)) {
173
+ navigator.serviceWorker.register("/sw.js");
174
+ // 홈 화면 앱은 닫히지 않고 며칠씩 다시 켜지기만 한다 — 돌아올 때(30분에 한 번) 새 배포가 있으면 새로고침
175
+ let mine = [], checked = Date.now();
176
+ addEventListener("DOMContentLoaded", () => { mine = [...document.scripts].map((s) => s.getAttribute("src")).filter((s) => s && s[0] === "/"); });
177
+ document.addEventListener("visibilitychange", () => {
178
+ if (document.hidden || Date.now() - checked < 18e5) return;
179
+ checked = Date.now();
180
+ fetch(location.pathname, { cache: "no-store" }).then((r) => (r.ok ? r.text() : ""))
181
+ .then((html) => { if (html && mine.some((s) => !html.includes(s))) location.reload(); }, () => {});
182
+ });
183
+ }
184
+ </script>`,
151
185
  `<!-- /m1kkit:pwa -->`,
152
186
  ].join("\n ");
153
187