@enbox/create-dapp 0.0.0-stage → 0.0.1

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.
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "enbox-dapp",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "typecheck": "tsc --noEmit",
9
+ "build": "npm run typecheck && vite build",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@enbox/browser": "0.3.82",
14
+ "@enbox/react": "0.0.6",
15
+ "react": "19.3.0",
16
+ "react-dom": "19.3.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "22.19.15",
20
+ "@types/react": "19.3.0",
21
+ "@types/react-dom": "19.2.3",
22
+ "@vitejs/plugin-react": "5.2.0",
23
+ "typescript": "5.9.3",
24
+ "vite": "6.4.4"
25
+ },
26
+ "engines": {
27
+ "node": ">=22.12.0"
28
+ }
29
+ }
@@ -0,0 +1,75 @@
1
+ import { useEnboxConnection } from '@enbox/react';
2
+ import { useEffect } from 'react';
3
+
4
+ import app from './app.json' with { type: 'json' };
5
+ import { Demo } from './demo/Demo.js';
6
+ import { ProfileCard } from './ProfileCard.js';
7
+
8
+ export function App() {
9
+ const connection = useEnboxConnection();
10
+ const busy = connection.isConnecting || connection.isDisconnecting;
11
+ useEffect(() => { document.title = app.name; }, []);
12
+
13
+ return (
14
+ <div className="app-shell">
15
+ <header className="app-header">
16
+ <a className="wordmark" href="./" aria-label={`${app.name} home`}>
17
+ <span className="brand-symbol" aria-hidden="true">e</span>
18
+ <span>Made with Enbox</span>
19
+ </a>
20
+ {connection.isConnected && (
21
+ <button className="button button-quiet" type="button" disabled={busy}
22
+ onClick={() => { void connection.disconnect().catch(() => {}); }}>
23
+ {connection.isDisconnecting ? 'Disconnecting…' : 'Disconnect'}
24
+ </button>
25
+ )}
26
+ </header>
27
+
28
+ <main>
29
+ <div className="intro">
30
+ <p className="eyebrow">A small beginning</p>
31
+ <h1>{app.name}</h1>
32
+ <p>Bring your profile. Make a little space your own.</p>
33
+ </div>
34
+
35
+ {connection.walletRepair && (
36
+ <div className="notice" role="status">
37
+ <div>
38
+ <strong>Reconnect your profile</strong>
39
+ <p>Your wallet access needs a refresh before you can save.</p>
40
+ </div>
41
+ <button className="button button-secondary" type="button" disabled={busy}
42
+ onClick={() => { void connection.connect().catch(() => {}); }}>
43
+ {connection.isConnecting ? 'Reconnecting…' : 'Reconnect'}
44
+ </button>
45
+ </div>
46
+ )}
47
+ {connection.connectionError && (
48
+ <div className="notice notice-error" role="alert">
49
+ <p>{connection.connectionError.message}</p>
50
+ </div>
51
+ )}
52
+
53
+ {connection.isConnected && connection.did ? (
54
+ <div className="connected-content">
55
+ <ProfileCard enbox={connection.enbox} did={connection.did} identityName={connection.identityName} />
56
+ <Demo disabled={busy || connection.walletRepair !== undefined} />
57
+ </div>
58
+ ) : (
59
+ <section className="panel connect-panel" aria-labelledby="connect-title">
60
+ <div className="welcome-symbol" aria-hidden="true">✳</div>
61
+ <h2 id="connect-title">A hello that belongs to you.</h2>
62
+ <p>Connect your profile to make this space your own.</p>
63
+ <button className="button button-primary" type="button" disabled={busy}
64
+ onClick={() => { void connection.connect().catch(() => {}); }}>
65
+ {connection.isConnecting ? 'Connecting…' : connection.isDisconnecting ? 'Disconnecting…' : 'Connect wallet'}
66
+ {!busy && <span aria-hidden="true">↗</span>}
67
+ </button>
68
+ <p className="connect-caption">Your wallet will ask you to approve access.</p>
69
+ </section>
70
+ )}
71
+ </main>
72
+ <footer className="app-footer">Your identity. Your data. Your little corner of the web.</footer>
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,54 @@
1
+ import type { Enbox } from '@enbox/browser';
2
+ import { useProfile } from '@enbox/react';
3
+ import { useState } from 'react';
4
+
5
+ export function ProfileCard({ enbox, did, identityName }: {
6
+ enbox: Enbox | undefined;
7
+ did: string;
8
+ identityName?: string | undefined;
9
+ }) {
10
+ const profile = useProfile(enbox, did);
11
+ const [failedAvatar, setFailedAvatar] = useState<string>();
12
+ const publishedName = profile.displayName?.trim();
13
+ const name = publishedName || identityName?.trim();
14
+ const avatar = profile.avatarUrl !== failedAvatar ? profile.avatarUrl : undefined;
15
+ const initials = name?.split(/\s+/).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
16
+ const profileStatus = profile.fields.profile.status;
17
+ const publicName = publishedName || (profileStatus === 'idle' || profileStatus === 'loading' ? 'Loading…'
18
+ : profileStatus === 'error' ? 'Unavailable' : 'Not published');
19
+ const shortDid = did.length > 34 ? `${did.slice(0, 22)}…${did.slice(-8)}` : did;
20
+
21
+ return (
22
+ <section className="panel profile-card" aria-labelledby="profile-title">
23
+ <div className="profile-heading">
24
+ <div className="avatar" aria-hidden="true">
25
+ {avatar
26
+ ? <img src={avatar} alt="" onError={() => setFailedAvatar(avatar)} />
27
+ : <span>{initials || '◌'}</span>}
28
+ </div>
29
+ <div className="profile-identity">
30
+ <p className="eyebrow">Your public profile</p>
31
+ <h2 id="profile-title">{name || 'Your profile'}</h2>
32
+ <p className="short-did" title={did}>{shortDid}</p>
33
+ </div>
34
+ <span className="connected-badge"><span aria-hidden="true" />Connected</span>
35
+ </div>
36
+ {profileStatus === 'loading' && !name && <p role="status">Looking up your public profile…</p>}
37
+ {profileStatus === 'not-found' && <p>No public profile yet. You can still make yourself at home.</p>}
38
+ {profileStatus === 'error' && (
39
+ <p className="profile-message" role="status">Your public profile couldn’t be loaded. The app is still yours to use.</p>
40
+ )}
41
+ <details className="profile-details">
42
+ <summary>Profile details</summary>
43
+ {profile.bio && <p className="profile-bio">{profile.bio}</p>}
44
+ <dl>
45
+ <dt>Profile ID</dt><dd><code>{did}</code></dd>
46
+ <dt>Public name</dt><dd>{publicName}</dd>
47
+ {profile.location && <><dt>Location</dt><dd>{profile.location}</dd></>}
48
+ {profile.website && <><dt>Website</dt><dd>{profile.website}</dd></>}
49
+ </dl>
50
+ {profile.fields.profile.failure && <p className="error-detail">{profile.fields.profile.failure.message}</p>}
51
+ </details>
52
+ </section>
53
+ );
54
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "id": "enbox-dapp",
3
+ "name": "Hello, Enbox"
4
+ }
@@ -0,0 +1,10 @@
1
+ import { defineApplicationManifest } from '@enbox/react/config';
2
+ import type { CreateEnboxClientOptions } from '@enbox/react/config';
3
+
4
+ import app from './app.json' with { type: 'json' };
5
+ import { DemoProtocol } from './demo/protocol.js';
6
+
7
+ export const config = {
8
+ application: defineApplicationManifest({ protocols: [DemoProtocol] }),
9
+ wallet: { appName: app.name, applicationId: app.id },
10
+ } satisfies CreateEnboxClientOptions;
@@ -0,0 +1,48 @@
1
+ import { useEnboxMutation, useRecords } from '@enbox/react';
2
+
3
+ import { CHOICES, DemoProtocol } from './protocol.js';
4
+ import type { ChoiceEmoji } from './protocol.js';
5
+ import './demo.css';
6
+
7
+ export function Demo({ disabled = false }: { disabled?: boolean }) {
8
+ const choices = useRecords(DemoProtocol, 'choice', { materialize: true, pagination: { limit: 1 } });
9
+ const save = useEnboxMutation((enbox, emoji: ChoiceEmoji) =>
10
+ enbox.using(DemoProtocol).records.set('choice', { data: { emoji } }));
11
+ const observed = CHOICES.find(({ emoji }) => emoji === choices.records[0]?.value.emoji);
12
+
13
+ return (
14
+ <section className="panel demo" aria-labelledby="demo-title">
15
+ <h2 id="demo-title">Your first record</h2>
16
+ <p>Choose a little hello. It’s encrypted for this app, separate from your public profile.</p>
17
+ <fieldset className="choice-options" disabled={disabled || save.isPending || choices.status !== 'ready'}>
18
+ <legend className="sr-only">Choose your hello</legend>
19
+ {CHOICES.map(({ emoji, label }) => (
20
+ <button type="button" className="choice-option" key={emoji}
21
+ aria-label={`Choose ${label.toLowerCase()}`}
22
+ aria-pressed={observed?.emoji === emoji}
23
+ onClick={() => { void save.run(emoji).catch(() => {}); }}>
24
+ <span className="choice-emoji" aria-hidden="true">{emoji}</span>
25
+ <span>{label}</span>
26
+ </button>
27
+ ))}
28
+ </fieldset>
29
+ <p className="demo-status" role="status">
30
+ {save.isPending ? 'Saving your choice…'
31
+ : choices.status === 'loading' ? 'Reading your saved choice…'
32
+ : observed ? `Saved: ${observed.emoji} ${observed.label}` : 'Choose an emoji to get started.'}
33
+ </p>
34
+ {choices.status === 'error' && (
35
+ <div className="notice notice-error" role="alert">
36
+ <p>Couldn’t read your choice: {choices.error.message}</p>
37
+ <button type="button" className="button button-secondary" disabled={disabled || save.isPending}
38
+ onClick={() => { void choices.retry().catch(() => {}); }}>
39
+ Try again
40
+ </button>
41
+ </div>
42
+ )}
43
+ {save.error && (
44
+ <p className="error-detail" role="alert">Couldn’t save: {save.error.message} Choose an emoji again to retry.</p>
45
+ )}
46
+ </section>
47
+ );
48
+ }
@@ -0,0 +1,17 @@
1
+ .demo { padding: 30px; }
2
+ .demo > p { margin-top: 12px; color: var(--muted); font-size: .85rem; }
3
+ .choice-options { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 11px; padding: 0; margin: 24px 0 0; border: 0; min-width: 0; }
4
+ .choice-option { display: grid; place-items: center; gap: 10px; padding: 20px 8px 17px; border: 1px solid var(--border); border-radius: 13px; background: #fff; color: #59645a; font-size: .75rem; transition: background .15s, border-color .15s; }
5
+ .choice-option:hover:not(:disabled) { background: #f8faf5; border-color: #a6bea0; }
6
+ .choice-option[aria-pressed="true"] { background: #edf3e7; border-color: #709865; color: #315b46; }
7
+ .choice-emoji { font-size: 1.9rem; line-height: 1.3; }
8
+ .demo .demo-status { min-height: 20px; margin-top: 18px; font-size: .74rem; text-align: center; }
9
+ .demo > p[role="alert"] { color: #7d4533; }
10
+ .demo .notice { margin: 16px 0 0; }
11
+ @media (max-width: 600px) {
12
+ .demo { padding: 26px 20px; }
13
+ .choice-options { gap: 8px; }
14
+ }
15
+ @media (prefers-reduced-motion: reduce) {
16
+ .choice-option { transition: none; }
17
+ }
@@ -0,0 +1,28 @@
1
+ import { defineProtocol, recordCodecs } from '@enbox/react/config';
2
+ import type { RecordValidator } from '@enbox/react/config';
3
+
4
+ import app from '../app.json' with { type: 'json' };
5
+
6
+ export const CHOICES = [
7
+ { emoji: '🌱', label: 'Grow' },
8
+ { emoji: '✨', label: 'Spark' },
9
+ { emoji: '🚀', label: 'Explore' },
10
+ ] as const;
11
+
12
+ export type ChoiceEmoji = typeof CHOICES[number]['emoji'];
13
+ export type Choice = { emoji: ChoiceEmoji };
14
+
15
+ const validateChoice: RecordValidator = (value: unknown): boolean =>
16
+ value !== null && typeof value === 'object' && !Array.isArray(value)
17
+ && 'emoji' in value && CHOICES.some(({ emoji }) => emoji === value.emoji);
18
+
19
+ // Replace this example URI with one you own before publishing your app.
20
+ // Keep it stable after launch: it identifies the app's saved data and grants.
21
+ export const DemoProtocol = defineProtocol({
22
+ protocol: `https://example.com/${encodeURIComponent(app.id)}/demo/v1`,
23
+ published: false,
24
+ types: {
25
+ choice: { dataFormats: ['application/json'], encryptionRequired: true },
26
+ },
27
+ structure: { choice: { $recordLimit: { max: 1 } } },
28
+ } as const, { choice: recordCodecs.json<Choice>({ validator: validateChoice }) });
@@ -0,0 +1,52 @@
1
+ import { activatePolyfills } from '@enbox/browser';
2
+ import { EnboxProvider } from '@enbox/react';
3
+ import { StrictMode } from 'react';
4
+ import { createRoot } from 'react-dom/client';
5
+
6
+ import { App } from './App.js';
7
+ import { config } from './application.js';
8
+ import workerPath from './sw.ts?worker&url';
9
+ import './style.css';
10
+
11
+ /** Wait for this app's worker to control the page before initializing Enbox. */
12
+ async function prepareBrowser(): Promise<void> {
13
+ if (!('serviceWorker' in navigator)) throw new Error('This browser needs service worker support.');
14
+ const workerUrl = new URL(workerPath, window.location.href).href;
15
+ let timer: ReturnType<typeof setTimeout> | undefined;
16
+ let finished = false;
17
+ let stopListening = (): void => {};
18
+ try {
19
+ await Promise.race([
20
+ (async () => {
21
+ await navigator.serviceWorker.register(workerUrl, { type: 'module', scope: import.meta.env.BASE_URL });
22
+ if (finished) throw new Error('Service worker startup wait ended.');
23
+ await navigator.serviceWorker.ready;
24
+ if (finished) throw new Error('Service worker startup wait ended.');
25
+ // A ready registration does not guarantee that our worker controls this page.
26
+ await new Promise<void>((resolve) => {
27
+ const resolveWhenControlled = (): void => {
28
+ if (navigator.serviceWorker.controller?.scriptURL === workerUrl) resolve();
29
+ };
30
+ navigator.serviceWorker.addEventListener('controllerchange', resolveWhenControlled);
31
+ stopListening = () => navigator.serviceWorker.removeEventListener('controllerchange', resolveWhenControlled);
32
+ resolveWhenControlled();
33
+ });
34
+ })(),
35
+ new Promise<never>((_resolve, reject) => {
36
+ timer = setTimeout(() => reject(new Error('The app service worker did not become ready. Reload to try again.')), 20_000);
37
+ }),
38
+ ]);
39
+ activatePolyfills({ serviceWorker: false });
40
+ } finally {
41
+ finished = true;
42
+ clearTimeout(timer);
43
+ stopListening();
44
+ }
45
+ }
46
+
47
+ void prepareBrowser().then(() => {
48
+ createRoot(document.getElementById('root')!).render(<StrictMode><EnboxProvider config={config}><App /></EnboxProvider></StrictMode>);
49
+ }).catch((cause: unknown) => {
50
+ const root = document.getElementById('root')!;
51
+ root.textContent = cause instanceof Error ? cause.message : 'Could not open the app.';
52
+ });
@@ -0,0 +1,108 @@
1
+ :root {
2
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
3
+ color: #27342e;
4
+ background: #f6f5f0;
5
+ font-synthesis: none;
6
+ text-rendering: optimizeLegibility;
7
+ -webkit-font-smoothing: antialiased;
8
+ color-scheme: light;
9
+ --muted: #68716b;
10
+ --accent: #315b46;
11
+ --border: #e1e4dc;
12
+ }
13
+
14
+ * { box-sizing: border-box; }
15
+ body { margin: 0; min-width: 320px; }
16
+ button, input { font: inherit; }
17
+ button { cursor: pointer; }
18
+ button:disabled { cursor: default; opacity: .52; }
19
+ :focus-visible { outline: 3px solid #709e76; outline-offset: 4px; }
20
+ a { color: inherit; }
21
+ p { margin: 0; line-height: 1.65; }
22
+ h1, h2 { margin: 0; line-height: 1.15; }
23
+ h2 { font-size: 1.3rem; font-weight: 600; letter-spacing: -.035em; }
24
+
25
+ .app-shell { max-width: 1040px; margin: 0 auto; padding: 0 36px; }
26
+ .app-header {
27
+ min-height: 108px;
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: space-between;
31
+ gap: 16px;
32
+ }
33
+ .wordmark { display: inline-flex; gap: 10px; align-items: center; text-decoration: none; font-size: .8rem; font-weight: 600; }
34
+ .brand-symbol {
35
+ width: 28px;
36
+ height: 28px;
37
+ display: grid;
38
+ place-items: center;
39
+ border-radius: 9px;
40
+ background: var(--accent);
41
+ color: #fff;
42
+ font: italic 25px/.9 Georgia, serif;
43
+ }
44
+ main { max-width: 640px; margin: 45px auto 0; }
45
+ .intro { margin-bottom: 36px; }
46
+ .eyebrow { color: var(--muted); font-size: .65rem; font-weight: 650; letter-spacing: .14em; text-transform: uppercase; }
47
+ .intro h1 { margin: 10px 0 15px; font-family: Georgia, "Times New Roman", serif; font-size: clamp(2.9rem, 7vw, 4.3rem); font-weight: 400; letter-spacing: -.065em; overflow-wrap: anywhere; }
48
+ .intro > p:last-child { color: var(--muted); font-size: .94rem; }
49
+ .panel { background: #fff; border: 1px solid var(--border); border-radius: 22px; box-shadow: 0 6px 24px #27342e04; }
50
+ .connected-content { display: grid; gap: 20px; }
51
+
52
+ .button { display: inline-flex; align-items: center; justify-content: center; gap: 18px; min-height: 42px; padding: 10px 17px; border: 1px solid transparent; border-radius: 10px; font-size: .84rem; font-weight: 550; transition: background .15s, border-color .15s; }
53
+ .button-primary { background: var(--accent); color: #fff; }
54
+ .button-primary:hover:not(:disabled) { background: #264836; }
55
+ .button-secondary { background: #fff; color: var(--accent); border-color: #b8ccbb; }
56
+ .button-secondary:hover:not(:disabled) { background: #f3f7f0; }
57
+ .button-quiet { background: transparent; border-color: var(--border); color: var(--muted); font-size: .77rem; min-height: 36px; }
58
+ .button-quiet:hover:not(:disabled) { border-color: #a6b4a7; color: var(--accent); }
59
+
60
+ .connect-panel { padding: 42px 40px; text-align: center; }
61
+ .welcome-symbol { display: grid; place-items: center; width: 66px; height: 66px; margin: 0 auto 25px; border-radius: 22px; color: var(--accent); background: #eef2e8; font-size: 2.6rem; }
62
+ .connect-panel h2 { font-size: 1.45rem; }
63
+ .connect-panel > p { max-width: 350px; margin: 13px auto 24px; color: var(--muted); font-size: .88rem; }
64
+ .connect-panel .button { min-width: 190px; }
65
+ .connect-panel .connect-caption { margin: 14px 0 0; max-width: none; font-size: .73rem; }
66
+
67
+ .profile-card { padding: 24px 26px; }
68
+ .profile-heading { display: flex; align-items: center; gap: 15px; }
69
+ .avatar { display: grid; place-items: center; width: 52px; height: 52px; flex: 0 0 auto; overflow: hidden; border-radius: 50%; background: #edece5; color: #626d59; font: 1.15rem Georgia, serif; }
70
+ .avatar img { width: 100%; height: 100%; object-fit: cover; }
71
+ .profile-identity { min-width: 0; flex: 1; }
72
+ .profile-identity .eyebrow { font-size: .58rem; letter-spacing: .1em; }
73
+ .profile-identity h2 { font-size: 1.08rem; margin: 5px 0; overflow-wrap: anywhere; }
74
+ .short-did { color: var(--muted); font-size: .69rem; font-family: ui-monospace, monospace; overflow-wrap: anywhere; }
75
+ .connected-badge { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); font-size: .65rem; white-space: nowrap; }
76
+ .connected-badge > span { width: 5px; height: 5px; background: #638b5b; border-radius: 50%; }
77
+ .profile-card > p { margin-top: 16px; color: var(--muted); font-size: .8rem; }
78
+ .profile-details { border-top: 1px solid #edf0e8; margin-top: 20px; padding-top: 13px; }
79
+ .profile-details summary { width: fit-content; cursor: pointer; color: var(--muted); font-size: .74rem; }
80
+ .profile-details summary:hover { color: var(--accent); }
81
+ .profile-details dl { margin: 18px 0 0; font-size: .78rem; }
82
+ .profile-details dt { margin-top: 12px; color: var(--muted); font-size: .69rem; }
83
+ .profile-details dd { margin: 4px 0 0; overflow-wrap: anywhere; }
84
+ .profile-details code { font-family: ui-monospace, monospace; font-size: .72rem; }
85
+ .profile-bio { margin-top: 15px; color: var(--muted); font-size: .82rem; white-space: pre-wrap; }
86
+
87
+ .notice { margin-bottom: 18px; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid #ccd9c5; border-radius: 13px; padding: 15px 18px; background: #edf3e8; font-size: .82rem; }
88
+ .notice p { flex-basis: 100%; }
89
+ .notice strong { font-weight: 600; }
90
+ .notice-error { background: #fff5ef; border-color: #eed2c3; color: #7d4533; }
91
+ .error-detail { font-size: .73rem; overflow-wrap: anywhere; }
92
+ .app-footer { padding: 38px 0; color: #7f857e; text-align: center; font-size: .69rem; }
93
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
94
+
95
+ @media (max-width: 600px) {
96
+ .app-shell { padding: 0 20px; }
97
+ .app-header { min-height: 84px; }
98
+ main { margin-top: 27px; }
99
+ .intro { margin-bottom: 27px; }
100
+ .connect-panel { padding: 34px 22px; }
101
+ .profile-card { padding: 22px 20px; }
102
+ .connected-badge { align-self: flex-start; margin-top: 4px; font-size: 0; }
103
+ .connected-badge > span { width: 7px; height: 7px; }
104
+ .app-footer { font-size: .65rem; }
105
+ }
106
+ @media (prefers-reduced-motion: reduce) {
107
+ .button { transition: none; }
108
+ }
@@ -0,0 +1,40 @@
1
+ /// <reference lib="webworker" />
2
+ import { activatePolyfills } from '@enbox/browser';
3
+
4
+ declare let self: ServiceWorkerGlobalScope;
5
+ declare const __APP_SHELL__: { version: string; files: string[] };
6
+
7
+ // Vite injects only production outputs; development uses its live module server.
8
+ if (import.meta.env.PROD) {
9
+ const shell = __APP_SHELL__;
10
+ const prefix = `enbox-shell:${self.registration.scope}:`;
11
+ const cacheName = `${prefix}${shell.version}`;
12
+ const root = new URL(self.registration.scope);
13
+ const index = new URL('index.html', root);
14
+ const files = shell.files.map((file) => new URL(file, self.registration.scope).href);
15
+
16
+ self.addEventListener('install', (event) => {
17
+ event.waitUntil(caches.open(cacheName).then((cache) =>
18
+ cache.addAll(files.map((url) => new Request(url, { cache: 'reload' })))));
19
+ });
20
+ self.addEventListener('activate', (event) => {
21
+ event.waitUntil(caches.keys().then((names) => Promise.all(
22
+ names.filter((name) => name.startsWith(prefix) && name !== cacheName).map((name) => caches.delete(name)),
23
+ )));
24
+ });
25
+ self.addEventListener('fetch', (event) => {
26
+ if (event.request.method !== 'GET') return;
27
+ const url = new URL(event.request.url);
28
+ // This starter has no router: leave other navigation and API routes alone.
29
+ const isIndex = event.request.mode === 'navigate' && url.origin === index.origin &&
30
+ (url.pathname === index.pathname || url.pathname === root.pathname);
31
+ const target = isIndex ? index.href : url.href;
32
+ if (files.includes(target)) {
33
+ event.respondWith(caches.open(cacheName).then(async (cache) =>
34
+ await cache.match(target) ?? fetch(event.request)));
35
+ }
36
+ });
37
+ }
38
+
39
+ // The current browser SDK performs automatic worker takeover.
40
+ activatePolyfills({ onCacheCheck: () => ({ ttl: 30_000 }) });
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "exactOptionalPropertyTypes": true,
11
+ "noUnusedLocals": true,
12
+ "noUnusedParameters": true,
13
+ "verbatimModuleSyntax": true,
14
+ "resolveJsonModule": true,
15
+ "skipLibCheck": true,
16
+ "noEmit": true,
17
+ "types": ["vite/client", "node"]
18
+ },
19
+ "include": ["src", "vite.config.ts"]
20
+ }
@@ -0,0 +1,47 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ import react from '@vitejs/plugin-react';
5
+ import { defineConfig, normalizePath } from 'vite';
6
+
7
+ const appWorker = normalizePath(fileURLToPath(new URL('./src/sw.ts', import.meta.url)));
8
+
9
+ export default defineConfig({
10
+ plugins: [
11
+ react(),
12
+ {
13
+ name: 'app-shell',
14
+ apply: 'build',
15
+ generateBundle: {
16
+ // Wait for Vite to emit the worker and finalize index.html.
17
+ order: 'post',
18
+ handler(_options, bundle) {
19
+ const worker = bundle['sw.js'];
20
+ if (worker?.type !== 'asset' || typeof worker.source !== 'string') {
21
+ this.error('The app service worker was not emitted.');
22
+ }
23
+ const files = Object.keys(bundle).filter((file) => !file.endsWith('.map')).sort();
24
+ const hash = createHash('sha256');
25
+ for (const file of files) {
26
+ const output = bundle[file]!;
27
+ hash.update(file).update('\0').update(output.type === 'chunk' ? output.code : output.source);
28
+ }
29
+ worker.source = worker.source.replace('__APP_SHELL__', JSON.stringify({
30
+ version: hash.digest('hex').slice(0, 16),
31
+ files: files.filter((file) => file !== 'sw.js'),
32
+ }));
33
+ },
34
+ },
35
+ },
36
+ ],
37
+ worker: {
38
+ format: 'es',
39
+ rollupOptions: {
40
+ output: {
41
+ entryFileNames: ({ facadeModuleId }) => facadeModuleId === appWorker ? 'sw.js' : 'assets/[name]-[hash].js',
42
+ },
43
+ },
44
+ },
45
+ server: { host: '127.0.0.1', headers: { 'Service-Worker-Allowed': '/' } },
46
+ preview: { host: '127.0.0.1' },
47
+ });