@nsite/stealthis 0.1.0
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/dist/nsite-deploy.js +546 -0
- package/dist/nsite-deploy.mjs +4999 -0
- package/package.json +25 -0
- package/src/index.ts +17 -0
- package/src/nostr.ts +296 -0
- package/src/qr.ts +8 -0
- package/src/signer.ts +90 -0
- package/src/styles.ts +440 -0
- package/src/widget.ts +629 -0
- package/tsconfig.json +12 -0
- package/vite.config.ts +13 -0
package/src/widget.ts
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import { STYLES } from './styles';
|
|
2
|
+
import * as nostr from './nostr';
|
|
3
|
+
import { toSvg } from './qr';
|
|
4
|
+
import {
|
|
5
|
+
hasExtension,
|
|
6
|
+
extensionSigner,
|
|
7
|
+
bunkerConnect,
|
|
8
|
+
prepareNostrConnect,
|
|
9
|
+
DEFAULT_NIP46_RELAY,
|
|
10
|
+
type Signer
|
|
11
|
+
} from './signer';
|
|
12
|
+
|
|
13
|
+
type State =
|
|
14
|
+
| 'idle'
|
|
15
|
+
| 'auth'
|
|
16
|
+
| 'connecting'
|
|
17
|
+
| 'loading'
|
|
18
|
+
| 'form'
|
|
19
|
+
| 'confirm'
|
|
20
|
+
| 'deploying'
|
|
21
|
+
| 'success'
|
|
22
|
+
| 'error';
|
|
23
|
+
|
|
24
|
+
export class NsiteDeployButton extends HTMLElement {
|
|
25
|
+
static observedAttributes = ['label'];
|
|
26
|
+
|
|
27
|
+
private shadow: ShadowRoot;
|
|
28
|
+
private state: State = 'idle';
|
|
29
|
+
private ctx: nostr.NsiteContext | null = null;
|
|
30
|
+
private manifest: nostr.SignedEvent | null = null;
|
|
31
|
+
private signer: Signer | null = null;
|
|
32
|
+
private userPubkey = '';
|
|
33
|
+
private userRelays: string[] = [];
|
|
34
|
+
private deployedUrl = '';
|
|
35
|
+
private errorMsg = '';
|
|
36
|
+
private statusMsg = '';
|
|
37
|
+
private slug = '';
|
|
38
|
+
private siteTitle = '';
|
|
39
|
+
private siteDescription = '';
|
|
40
|
+
private deployAsRoot = true;
|
|
41
|
+
private hasRootSite: boolean | null = null; // null = still checking
|
|
42
|
+
private nostrConnectUri = '';
|
|
43
|
+
private nip46Relay = DEFAULT_NIP46_RELAY;
|
|
44
|
+
private qrAbort: AbortController | null = null;
|
|
45
|
+
private ncConnect: ReturnType<typeof prepareNostrConnect> | null = null;
|
|
46
|
+
private manifestPromise: Promise<nostr.SignedEvent | null> | null = null;
|
|
47
|
+
private relaysPromise: Promise<string[]> | null = null;
|
|
48
|
+
private thieves: nostr.Thief[] = [];
|
|
49
|
+
private thievesExpanded = false;
|
|
50
|
+
|
|
51
|
+
constructor() {
|
|
52
|
+
super();
|
|
53
|
+
this.shadow = this.attachShadow({ mode: 'open' });
|
|
54
|
+
this.ctx = nostr.parseContext();
|
|
55
|
+
if (this.ctx) {
|
|
56
|
+
this.manifestPromise = nostr.fetchManifest(this.ctx);
|
|
57
|
+
this.manifestPromise.then((manifest) => {
|
|
58
|
+
if (manifest) {
|
|
59
|
+
this.thieves = nostr.extractThieves(manifest);
|
|
60
|
+
if (this.thieves.length > 0 && this.state === 'idle') this.render();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
this.render();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
attributeChangedCallback() {
|
|
68
|
+
if (this.state === 'idle') this.render();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private get buttonLabel(): string {
|
|
72
|
+
return this.getAttribute('label') || 'Borrow this';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private esc(s: string): string {
|
|
76
|
+
return s
|
|
77
|
+
.replace(/&/g, '&')
|
|
78
|
+
.replace(/</g, '<')
|
|
79
|
+
.replace(/>/g, '>')
|
|
80
|
+
.replace(/"/g, '"');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private modal(body: string): string {
|
|
84
|
+
return `<div class="nd-overlay"><div class="nd-modal">${body}</div></div>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private render() {
|
|
88
|
+
this.preserveFormValues();
|
|
89
|
+
let html = `<style>${STYLES}</style>`;
|
|
90
|
+
|
|
91
|
+
switch (this.state) {
|
|
92
|
+
case 'idle':
|
|
93
|
+
html += `<button class="nd-trigger" part="trigger">${this.esc(this.buttonLabel)}</button>`;
|
|
94
|
+
html += this.paperTrailContent();
|
|
95
|
+
break;
|
|
96
|
+
|
|
97
|
+
case 'auth':
|
|
98
|
+
html += `<button class="nd-trigger" disabled>${this.esc(this.buttonLabel)}</button>`;
|
|
99
|
+
html += this.modal(this.authContent());
|
|
100
|
+
break;
|
|
101
|
+
|
|
102
|
+
case 'connecting':
|
|
103
|
+
html += `<button class="nd-trigger" disabled>Connecting...</button>`;
|
|
104
|
+
html += this.modal(`
|
|
105
|
+
<div class="nd-header">
|
|
106
|
+
<h2 class="nd-title">Connecting</h2>
|
|
107
|
+
<button class="nd-close" data-action="close">×</button>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="nd-msg"><span class="nd-spinner"></span>${this.esc(this.statusMsg)}</div>
|
|
110
|
+
`);
|
|
111
|
+
break;
|
|
112
|
+
|
|
113
|
+
case 'loading':
|
|
114
|
+
html += `<button class="nd-trigger" disabled>Loading...</button>`;
|
|
115
|
+
html += this.modal(`
|
|
116
|
+
<div class="nd-msg"><span class="nd-spinner"></span>Fetching site manifest...</div>
|
|
117
|
+
`);
|
|
118
|
+
break;
|
|
119
|
+
|
|
120
|
+
case 'form':
|
|
121
|
+
html += `<button class="nd-trigger" disabled>${this.esc(this.buttonLabel)}</button>`;
|
|
122
|
+
html += this.modal(this.formContent());
|
|
123
|
+
break;
|
|
124
|
+
|
|
125
|
+
case 'confirm':
|
|
126
|
+
html += `<button class="nd-trigger" disabled>${this.esc(this.buttonLabel)}</button>`;
|
|
127
|
+
html += this.modal(this.confirmContent());
|
|
128
|
+
break;
|
|
129
|
+
|
|
130
|
+
case 'deploying':
|
|
131
|
+
html += `<button class="nd-trigger" disabled>Deploying...</button>`;
|
|
132
|
+
html += this.modal(`
|
|
133
|
+
<h2 class="nd-title">Deploying</h2>
|
|
134
|
+
<div class="nd-msg"><span class="nd-spinner"></span>${this.esc(this.statusMsg)}</div>
|
|
135
|
+
`);
|
|
136
|
+
break;
|
|
137
|
+
|
|
138
|
+
case 'success':
|
|
139
|
+
html += `<button class="nd-trigger" disabled>Deployed!</button>`;
|
|
140
|
+
html += this.modal(`
|
|
141
|
+
<div class="nd-header">
|
|
142
|
+
<h2 class="nd-title">Deployed!</h2>
|
|
143
|
+
<button class="nd-close" data-action="close">×</button>
|
|
144
|
+
</div>
|
|
145
|
+
<div class="nd-status nd-status-ok">Your site is live</div>
|
|
146
|
+
<a class="nd-link" href="${this.esc(this.deployedUrl)}" target="_blank" rel="noopener">${this.esc(this.deployedUrl)}</a>
|
|
147
|
+
<div class="nd-actions">
|
|
148
|
+
<button class="nd-btn nd-btn-secondary" data-action="close">Close</button>
|
|
149
|
+
</div>
|
|
150
|
+
`);
|
|
151
|
+
break;
|
|
152
|
+
|
|
153
|
+
case 'error':
|
|
154
|
+
html += `<button class="nd-trigger" part="trigger">${this.esc(this.buttonLabel)}</button>`;
|
|
155
|
+
html += this.modal(`
|
|
156
|
+
<div class="nd-header">
|
|
157
|
+
<h2 class="nd-title">Error</h2>
|
|
158
|
+
<button class="nd-close" data-action="close">×</button>
|
|
159
|
+
</div>
|
|
160
|
+
<div class="nd-status nd-status-err">${this.esc(this.errorMsg)}</div>
|
|
161
|
+
<div class="nd-actions">
|
|
162
|
+
<button class="nd-btn nd-btn-secondary" data-action="close">Close</button>
|
|
163
|
+
</div>
|
|
164
|
+
`);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this.shadow.innerHTML = html;
|
|
169
|
+
this.bind();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- Content builders ---
|
|
173
|
+
|
|
174
|
+
private paperTrailContent(): string {
|
|
175
|
+
if (this.thieves.length === 0) return '';
|
|
176
|
+
|
|
177
|
+
let html = `<div class="nd-trail">
|
|
178
|
+
<button class="nd-trail-toggle" data-action="toggle-trail">Stolen by ${this.thieves.length} npub${this.thieves.length === 1 ? '' : 's'}</button>`;
|
|
179
|
+
|
|
180
|
+
if (this.thievesExpanded) {
|
|
181
|
+
html += `<div class="nd-trail-list">`;
|
|
182
|
+
const first = this.thieves[0];
|
|
183
|
+
const second = this.thieves.length > 1 ? this.thieves[1] : null;
|
|
184
|
+
|
|
185
|
+
// Show originator
|
|
186
|
+
html += this.thiefItem(first);
|
|
187
|
+
|
|
188
|
+
// Show gap if indices aren't sequential (truncated middle)
|
|
189
|
+
if (second && second.index > first.index + 1) {
|
|
190
|
+
const truncated = second.index - first.index - 1;
|
|
191
|
+
html += `<div class="nd-trail-gap">... ${truncated} more</div>`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Show the rest
|
|
195
|
+
for (let i = 1; i < this.thieves.length; i++) {
|
|
196
|
+
html += this.thiefItem(this.thieves[i]);
|
|
197
|
+
}
|
|
198
|
+
html += `</div>`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
html += `</div>`;
|
|
202
|
+
return html;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private thiefItem(thief: nostr.Thief): string {
|
|
206
|
+
const npub = nostr.npubEncode(thief.pubkey);
|
|
207
|
+
const short = npub.slice(0, 12) + '...' + npub.slice(-4);
|
|
208
|
+
return `<div class="nd-trail-item">
|
|
209
|
+
<span class="nd-trail-idx">#${thief.index}</span>
|
|
210
|
+
<span class="nd-trail-pk">${short}</span>
|
|
211
|
+
</div>`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private authContent(): string {
|
|
215
|
+
const ext = hasExtension();
|
|
216
|
+
const qrSvg = this.nostrConnectUri ? toSvg(this.nostrConnectUri) : '';
|
|
217
|
+
|
|
218
|
+
let html = `
|
|
219
|
+
<div class="nd-header">
|
|
220
|
+
<h2 class="nd-title">Sign In</h2>
|
|
221
|
+
<button class="nd-close" data-action="close">×</button>
|
|
222
|
+
</div>`;
|
|
223
|
+
|
|
224
|
+
if (ext) {
|
|
225
|
+
html += `
|
|
226
|
+
<div class="nd-auth-option">
|
|
227
|
+
<button class="nd-btn-ext" data-action="ext">Sign in with Extension</button>
|
|
228
|
+
</div>
|
|
229
|
+
<div class="nd-divider">or</div>`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
html += `
|
|
233
|
+
<div class="nd-auth-option">
|
|
234
|
+
<div class="nd-qr-label" style="margin-bottom:6px">Paste a bunker URI</div>
|
|
235
|
+
<div class="nd-bunker-row">
|
|
236
|
+
<input type="text" placeholder="bunker://..." id="nd-bunker" />
|
|
237
|
+
<button data-action="bunker">Connect</button>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
<div class="nd-divider">or scan</div>
|
|
241
|
+
<div class="nd-auth-option">
|
|
242
|
+
<div class="nd-qr-wrap">
|
|
243
|
+
<div class="nd-qr-label">Scan with your signer app</div>
|
|
244
|
+
<div class="nd-qr-code">${qrSvg}</div>
|
|
245
|
+
<div class="nd-relay-row">
|
|
246
|
+
<label>relay</label>
|
|
247
|
+
<input type="text" id="nd-nip46-relay" value="${this.esc(this.nip46Relay)}" />
|
|
248
|
+
</div>
|
|
249
|
+
<div class="nd-qr-uri">
|
|
250
|
+
<input readonly value="${this.esc(this.nostrConnectUri)}" id="nd-nc-uri" />
|
|
251
|
+
<button data-action="copy-uri">Copy</button>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</div>`;
|
|
255
|
+
|
|
256
|
+
return html;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private formContent(): string {
|
|
260
|
+
let html = `
|
|
261
|
+
<div class="nd-header">
|
|
262
|
+
<h2 class="nd-title">Deploy this Page</h2>
|
|
263
|
+
<button class="nd-close" data-action="close">×</button>
|
|
264
|
+
</div>
|
|
265
|
+
${this.hasRootSite ? `<div class="nd-toggle">
|
|
266
|
+
<button class="nd-toggle-btn ${this.deployAsRoot ? 'active' : ''}" data-action="type-root">Root Site</button>
|
|
267
|
+
<button class="nd-toggle-btn ${!this.deployAsRoot ? 'active' : ''}" data-action="type-named">Named Site</button>
|
|
268
|
+
</div>` : ''}`;
|
|
269
|
+
|
|
270
|
+
if (this.deployAsRoot) {
|
|
271
|
+
html += `<div class="nd-root-hint">Your primary site, served at your npub subdomain.</div>`;
|
|
272
|
+
} else {
|
|
273
|
+
html += `
|
|
274
|
+
<div class="nd-root-hint">A sub-site with its own name, served alongside your root site.</div>
|
|
275
|
+
<div class="nd-field">
|
|
276
|
+
<label for="nd-slug">Site name</label>
|
|
277
|
+
<input id="nd-slug" type="text" placeholder="my-site" value="${this.esc(this.slug)}" maxlength="13" autocomplete="off" />
|
|
278
|
+
<div class="nd-hint">Lowercase a-z, 0-9, hyphens. 1-13 chars.</div>
|
|
279
|
+
<div class="nd-field-error" id="nd-slug-err"></div>
|
|
280
|
+
</div>`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
html += `
|
|
284
|
+
<div class="nd-field">
|
|
285
|
+
<label for="nd-title">Title</label>
|
|
286
|
+
<input id="nd-title" type="text" placeholder="Optional" value="${this.esc(this.siteTitle)}" />
|
|
287
|
+
</div>
|
|
288
|
+
<div class="nd-field">
|
|
289
|
+
<label for="nd-desc">Description</label>
|
|
290
|
+
<textarea id="nd-desc" placeholder="Optional">${this.esc(this.siteDescription)}</textarea>
|
|
291
|
+
</div>
|
|
292
|
+
<div class="nd-actions">
|
|
293
|
+
<button class="nd-btn nd-btn-secondary" data-action="close">Cancel</button>
|
|
294
|
+
<button class="nd-btn nd-btn-primary" data-action="deploy" ${this.hasRootSite === null ? 'disabled' : ''}>
|
|
295
|
+
${this.hasRootSite === null ? '<span class="nd-spinner"></span>Checking...' : 'Deploy'}
|
|
296
|
+
</button>
|
|
297
|
+
</div>`;
|
|
298
|
+
|
|
299
|
+
return html;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private confirmContent(): string {
|
|
303
|
+
const what = this.deployAsRoot ? 'a root site' : `a site named "${this.esc(this.slug)}"`;
|
|
304
|
+
return `
|
|
305
|
+
<div class="nd-header">
|
|
306
|
+
<h2 class="nd-title">Site Already Exists</h2>
|
|
307
|
+
<button class="nd-close" data-action="close">×</button>
|
|
308
|
+
</div>
|
|
309
|
+
<div class="nd-warn">
|
|
310
|
+
You already have ${what}. Deploying will replace it with this page's content.
|
|
311
|
+
</div>
|
|
312
|
+
<div class="nd-actions">
|
|
313
|
+
<button class="nd-btn nd-btn-secondary" data-action="back">Back</button>
|
|
314
|
+
<button class="nd-btn nd-btn-warn" data-action="confirm-deploy">Overwrite</button>
|
|
315
|
+
</div>`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// --- Bindings ---
|
|
319
|
+
|
|
320
|
+
private bind() {
|
|
321
|
+
this.shadow
|
|
322
|
+
.querySelector('.nd-trigger:not([disabled])')
|
|
323
|
+
?.addEventListener('click', () => this.open());
|
|
324
|
+
this.shadow
|
|
325
|
+
.querySelectorAll('[data-action="close"]')
|
|
326
|
+
.forEach((el) => el.addEventListener('click', () => this.close()));
|
|
327
|
+
this.shadow
|
|
328
|
+
.querySelector('[data-action="deploy"]')
|
|
329
|
+
?.addEventListener('click', () => this.onDeploy());
|
|
330
|
+
this.shadow
|
|
331
|
+
.querySelector('[data-action="confirm-deploy"]')
|
|
332
|
+
?.addEventListener('click', () => this.executeDeploy());
|
|
333
|
+
this.shadow
|
|
334
|
+
.querySelector('[data-action="back"]')
|
|
335
|
+
?.addEventListener('click', () => this.setState('form'));
|
|
336
|
+
this.shadow
|
|
337
|
+
.querySelector('[data-action="ext"]')
|
|
338
|
+
?.addEventListener('click', () => this.authExtension());
|
|
339
|
+
this.shadow
|
|
340
|
+
.querySelector('[data-action="bunker"]')
|
|
341
|
+
?.addEventListener('click', () => this.authBunker());
|
|
342
|
+
this.shadow
|
|
343
|
+
.querySelector('[data-action="copy-uri"]')
|
|
344
|
+
?.addEventListener('click', () => this.copyUri());
|
|
345
|
+
this.shadow
|
|
346
|
+
.querySelector('[data-action="toggle-trail"]')
|
|
347
|
+
?.addEventListener('click', () => {
|
|
348
|
+
this.thievesExpanded = !this.thievesExpanded;
|
|
349
|
+
this.render();
|
|
350
|
+
});
|
|
351
|
+
this.shadow
|
|
352
|
+
.querySelector('[data-action="type-named"]')
|
|
353
|
+
?.addEventListener('click', () => {
|
|
354
|
+
this.deployAsRoot = false;
|
|
355
|
+
this.setState('form');
|
|
356
|
+
});
|
|
357
|
+
this.shadow
|
|
358
|
+
.querySelector('[data-action="type-root"]')
|
|
359
|
+
?.addEventListener('click', () => {
|
|
360
|
+
this.deployAsRoot = true;
|
|
361
|
+
this.setState('form');
|
|
362
|
+
});
|
|
363
|
+
this.shadow.querySelector('.nd-overlay')?.addEventListener('click', (e) => {
|
|
364
|
+
if ((e.target as HTMLElement).classList.contains('nd-overlay')) this.close();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Relay editing — regenerate QR on blur or Enter
|
|
368
|
+
const relayInput = this.shadow.querySelector('#nd-nip46-relay') as HTMLInputElement | null;
|
|
369
|
+
if (relayInput) {
|
|
370
|
+
const commitRelay = () => {
|
|
371
|
+
const v = relayInput.value.trim();
|
|
372
|
+
if (v && v !== this.nip46Relay) {
|
|
373
|
+
this.nip46Relay = v;
|
|
374
|
+
this.startNostrConnect();
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
relayInput.addEventListener('blur', commitRelay);
|
|
378
|
+
relayInput.addEventListener('keydown', (e) => {
|
|
379
|
+
if (e.key === 'Enter') {
|
|
380
|
+
e.preventDefault();
|
|
381
|
+
commitRelay();
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private preserveFormValues() {
|
|
388
|
+
if (this.state !== 'form') return;
|
|
389
|
+
const slug = this.shadow.querySelector('#nd-slug') as HTMLInputElement | null;
|
|
390
|
+
const title = this.shadow.querySelector('#nd-title') as HTMLInputElement | null;
|
|
391
|
+
const desc = this.shadow.querySelector('#nd-desc') as HTMLTextAreaElement | null;
|
|
392
|
+
if (slug) this.slug = slug.value;
|
|
393
|
+
if (title) this.siteTitle = title.value;
|
|
394
|
+
if (desc) this.siteDescription = desc.value;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private setState(s: State) {
|
|
398
|
+
this.state = s;
|
|
399
|
+
this.render();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private close() {
|
|
403
|
+
this.cancelQr();
|
|
404
|
+
this.signer?.close();
|
|
405
|
+
this.signer = null;
|
|
406
|
+
this.setState('idle');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private cancelQr() {
|
|
410
|
+
this.qrAbort?.abort();
|
|
411
|
+
this.qrAbort = null;
|
|
412
|
+
this.ncConnect = null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private showError(msg: string) {
|
|
416
|
+
this.errorMsg = msg;
|
|
417
|
+
this.setState('error');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// --- Nostrconnect lifecycle ---
|
|
421
|
+
|
|
422
|
+
private startNostrConnect() {
|
|
423
|
+
this.cancelQr();
|
|
424
|
+
|
|
425
|
+
this.ncConnect = prepareNostrConnect(this.nip46Relay);
|
|
426
|
+
this.nostrConnectUri = this.ncConnect.uri;
|
|
427
|
+
|
|
428
|
+
// Re-render to show new QR (only if we're on the auth screen)
|
|
429
|
+
if (this.state === 'auth') {
|
|
430
|
+
this.render();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Listen for connection in background
|
|
434
|
+
this.qrAbort = new AbortController();
|
|
435
|
+
this.ncConnect
|
|
436
|
+
.connect(this.qrAbort.signal)
|
|
437
|
+
.then((signer) => {
|
|
438
|
+
if (this.state === 'auth' || this.state === 'connecting') {
|
|
439
|
+
this.signer = signer;
|
|
440
|
+
this.onAuthenticated();
|
|
441
|
+
} else {
|
|
442
|
+
signer.close();
|
|
443
|
+
}
|
|
444
|
+
})
|
|
445
|
+
.catch(() => {
|
|
446
|
+
// Aborted or timed out
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// --- Auth ---
|
|
451
|
+
|
|
452
|
+
private async open() {
|
|
453
|
+
// Manifest fetch started in constructor; ensure it's running
|
|
454
|
+
if (!this.manifestPromise && this.ctx) {
|
|
455
|
+
this.manifestPromise = nostr.fetchManifest(this.ctx);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
this.state = 'auth';
|
|
459
|
+
this.startNostrConnect();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private async authExtension() {
|
|
463
|
+
this.cancelQr();
|
|
464
|
+
try {
|
|
465
|
+
this.signer = extensionSigner();
|
|
466
|
+
this.onAuthenticated();
|
|
467
|
+
} catch (err) {
|
|
468
|
+
this.showError(err instanceof Error ? err.message : String(err));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private async authBunker() {
|
|
473
|
+
const input = (this.shadow.querySelector('#nd-bunker') as HTMLInputElement)?.value.trim();
|
|
474
|
+
if (!input) return;
|
|
475
|
+
|
|
476
|
+
this.cancelQr();
|
|
477
|
+
this.statusMsg = 'Connecting to bunker...';
|
|
478
|
+
this.setState('connecting');
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
this.signer = await bunkerConnect(input);
|
|
482
|
+
this.onAuthenticated();
|
|
483
|
+
} catch (err) {
|
|
484
|
+
this.showError(err instanceof Error ? err.message : String(err));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private async copyUri() {
|
|
489
|
+
try {
|
|
490
|
+
await navigator.clipboard.writeText(this.nostrConnectUri);
|
|
491
|
+
const btn = this.shadow.querySelector('[data-action="copy-uri"]') as HTMLButtonElement;
|
|
492
|
+
if (btn) {
|
|
493
|
+
btn.textContent = 'Copied!';
|
|
494
|
+
setTimeout(() => {
|
|
495
|
+
if (btn.isConnected) btn.textContent = 'Copy';
|
|
496
|
+
}, 2000);
|
|
497
|
+
}
|
|
498
|
+
} catch {
|
|
499
|
+
/* clipboard unavailable */
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// --- Post-auth ---
|
|
504
|
+
|
|
505
|
+
private async onAuthenticated() {
|
|
506
|
+
this.setState('loading');
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
this.userPubkey = await this.signer!.getPublicKey();
|
|
510
|
+
this.manifest = (await this.manifestPromise) as nostr.SignedEvent | null;
|
|
511
|
+
|
|
512
|
+
if (!this.manifest) {
|
|
513
|
+
this.showError('Could not find the site manifest on any relay.');
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
this.siteTitle = '';
|
|
518
|
+
this.siteDescription = '';
|
|
519
|
+
this.slug = this.ctx!.identifier ?? '';
|
|
520
|
+
this.deployAsRoot = true;
|
|
521
|
+
this.hasRootSite = null;
|
|
522
|
+
|
|
523
|
+
// Show form immediately — don't block on relay discovery
|
|
524
|
+
this.setState('form');
|
|
525
|
+
|
|
526
|
+
// Background: fetch relays, then check if root site exists
|
|
527
|
+
this.relaysPromise = nostr.getWriteRelays(this.userPubkey);
|
|
528
|
+
this.relaysPromise.then(async (relays) => {
|
|
529
|
+
this.userRelays = relays;
|
|
530
|
+
const hasRoot = await nostr.checkExistingSite(relays, this.userPubkey);
|
|
531
|
+
this.hasRootSite = hasRoot;
|
|
532
|
+
if (hasRoot && this.state === 'form' && this.deployAsRoot) {
|
|
533
|
+
this.deployAsRoot = false;
|
|
534
|
+
}
|
|
535
|
+
if (this.state === 'form') this.render();
|
|
536
|
+
});
|
|
537
|
+
} catch (err) {
|
|
538
|
+
this.showError(err instanceof Error ? err.message : String(err));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// --- Deploy ---
|
|
543
|
+
|
|
544
|
+
private readFormValues() {
|
|
545
|
+
if (!this.deployAsRoot) {
|
|
546
|
+
const slugEl = this.shadow.querySelector('#nd-slug') as HTMLInputElement | null;
|
|
547
|
+
if (slugEl) this.slug = slugEl.value.trim();
|
|
548
|
+
}
|
|
549
|
+
const titleEl = this.shadow.querySelector('#nd-title') as HTMLInputElement | null;
|
|
550
|
+
const descEl = this.shadow.querySelector('#nd-desc') as HTMLTextAreaElement | null;
|
|
551
|
+
if (titleEl) this.siteTitle = titleEl.value.trim();
|
|
552
|
+
if (descEl) this.siteDescription = descEl.value.trim();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private async onDeploy() {
|
|
556
|
+
this.readFormValues();
|
|
557
|
+
|
|
558
|
+
if (!this.deployAsRoot) {
|
|
559
|
+
const errEl = this.shadow.querySelector('#nd-slug-err') as HTMLElement;
|
|
560
|
+
if (!this.slug) {
|
|
561
|
+
errEl.textContent = 'Site name is required';
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (!nostr.isValidDTag(this.slug)) {
|
|
565
|
+
errEl.textContent = 'Lowercase a-z, 0-9, hyphens only. Cannot end with hyphen.';
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
this.statusMsg = 'Checking for existing site...';
|
|
571
|
+
this.setState('deploying');
|
|
572
|
+
|
|
573
|
+
try {
|
|
574
|
+
// Ensure relays are ready (usually already resolved by now)
|
|
575
|
+
if (this.relaysPromise) {
|
|
576
|
+
this.userRelays = await this.relaysPromise;
|
|
577
|
+
}
|
|
578
|
+
if (this.userRelays.length === 0) {
|
|
579
|
+
this.userRelays = await nostr.getWriteRelays(this.userPubkey);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const slug = this.deployAsRoot ? undefined : this.slug;
|
|
583
|
+
const exists = await nostr.checkExistingSite(this.userRelays, this.userPubkey, slug);
|
|
584
|
+
|
|
585
|
+
if (exists) {
|
|
586
|
+
this.setState('confirm');
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
await this.executeDeploy();
|
|
591
|
+
} catch (err) {
|
|
592
|
+
this.showError(err instanceof Error ? err.message : String(err));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private async executeDeploy() {
|
|
597
|
+
this.statusMsg = 'Creating event...';
|
|
598
|
+
this.setState('deploying');
|
|
599
|
+
|
|
600
|
+
try {
|
|
601
|
+
const slug = this.deployAsRoot ? undefined : this.slug;
|
|
602
|
+
const unsigned = nostr.createDeployEvent(this.manifest!, {
|
|
603
|
+
slug,
|
|
604
|
+
title: this.siteTitle || undefined,
|
|
605
|
+
description: this.siteDescription || undefined,
|
|
606
|
+
deployerPubkey: this.userPubkey,
|
|
607
|
+
deployerRelays: this.userRelays
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
this.statusMsg = 'Waiting for signature...';
|
|
611
|
+
this.render();
|
|
612
|
+
const signed = await this.signer!.signEvent(unsigned);
|
|
613
|
+
|
|
614
|
+
this.statusMsg = `Publishing to ${this.userRelays.length} relay${this.userRelays.length === 1 ? '' : 's'}...`;
|
|
615
|
+
this.render();
|
|
616
|
+
const published = await nostr.publishToRelays(this.userRelays, signed);
|
|
617
|
+
|
|
618
|
+
if (published === 0) {
|
|
619
|
+
this.showError('Failed to publish to any relay. Please try again.');
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
this.deployedUrl = nostr.buildSiteUrl(this.ctx!.baseDomain, this.userPubkey, slug);
|
|
624
|
+
this.setState('success');
|
|
625
|
+
} catch (err) {
|
|
626
|
+
this.showError(err instanceof Error ? err.message : String(err));
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
package/tsconfig.json
ADDED
package/vite.config.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
build: {
|
|
5
|
+
lib: {
|
|
6
|
+
entry: 'src/index.ts',
|
|
7
|
+
name: '@nsite/stealthis',
|
|
8
|
+
formats: ['iife', 'es'],
|
|
9
|
+
fileName: (format) => (format === 'es' ? 'stealthis.mjs' : 'stealthis.js')
|
|
10
|
+
},
|
|
11
|
+
outDir: 'dist'
|
|
12
|
+
}
|
|
13
|
+
});
|