@hozu/devtools 0.11.0 → 0.13.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/overlay/app.js +34 -2
- package/dist/overlay/app.js.map +1 -1
- package/dist/overlay/effects.d.ts +55 -0
- package/dist/overlay/effects.d.ts.map +1 -0
- package/dist/overlay/effects.js +831 -0
- package/dist/overlay/effects.js.map +1 -0
- package/dist/overlay/style.d.ts +1 -1
- package/dist/overlay/style.d.ts.map +1 -1
- package/dist/overlay/style.js +110 -0
- package/dist/overlay/style.js.map +1 -1
- package/dist/overlay/trace.d.ts +22 -0
- package/dist/overlay/trace.d.ts.map +1 -0
- package/dist/overlay/trace.js +94 -0
- package/dist/overlay/trace.js.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
import { h, read, write } from './dom.js';
|
|
2
|
+
import { curl, traced } from './trace.js';
|
|
3
|
+
const getJson = (path) => fetch(path)
|
|
4
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
5
|
+
.catch(() => null);
|
|
6
|
+
export const effects = (path) => getJson(`/_hozu/dev/effects?path=${encodeURIComponent(path)}`);
|
|
7
|
+
const payloadOf = (doc) => {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(doc.getElementById('hozu-payload')?.textContent ?? 'null');
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
export function example(schema) {
|
|
16
|
+
if (!schema)
|
|
17
|
+
return null;
|
|
18
|
+
if (schema.default !== undefined)
|
|
19
|
+
return schema.default;
|
|
20
|
+
if (Array.isArray(schema.enum))
|
|
21
|
+
return schema.enum[0];
|
|
22
|
+
if ('const' in schema)
|
|
23
|
+
return schema.const;
|
|
24
|
+
const alternatives = schema.anyOf ?? schema.oneOf;
|
|
25
|
+
if (Array.isArray(alternatives))
|
|
26
|
+
return example(alternatives.find((a) => a.type !== 'null') ?? alternatives[0]);
|
|
27
|
+
const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
|
|
28
|
+
if (type === 'object')
|
|
29
|
+
return Object.fromEntries(Object.entries((schema.properties ?? {})).map(([k, v]) => [k, example(v)]));
|
|
30
|
+
if (type === 'array')
|
|
31
|
+
return schema.items ? [example(schema.items)] : [];
|
|
32
|
+
if (type === 'number' || type === 'integer')
|
|
33
|
+
return 0;
|
|
34
|
+
if (type === 'boolean')
|
|
35
|
+
return false;
|
|
36
|
+
if (type === 'null')
|
|
37
|
+
return null;
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
/** The input the page uses for a query (from its payload), else an example from the schema. */
|
|
41
|
+
function startInput(effect, doc) {
|
|
42
|
+
const data = (payloadOf(doc)?.data ?? []);
|
|
43
|
+
const key = data
|
|
44
|
+
.map(([k]) => k)
|
|
45
|
+
.find((k) => k.startsWith(effect.ref) && '{['.includes(k[effect.ref.length] ?? ''));
|
|
46
|
+
if (key)
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(key.slice(effect.ref.length));
|
|
49
|
+
}
|
|
50
|
+
catch { }
|
|
51
|
+
return example(effect.input);
|
|
52
|
+
}
|
|
53
|
+
const scalar = (s) => ['string', 'number', 'integer', 'boolean'].includes(s.type) || Array.isArray(s.enum);
|
|
54
|
+
const flat = (schema) => schema.type === 'object' && Object.values((schema.properties ?? {})).every((p) => scalar(p));
|
|
55
|
+
const hookOf = (win) => win.__hozu;
|
|
56
|
+
/** Runs an effect the way the page would: through the page's client when it has one, else the app's endpoints. */
|
|
57
|
+
async function call(effect, input, win) {
|
|
58
|
+
const started = performance.now();
|
|
59
|
+
const hook = hookOf(win);
|
|
60
|
+
const where = effect.runs === 'server' ? 'server' : 'browser';
|
|
61
|
+
const base = {
|
|
62
|
+
ref: effect.ref,
|
|
63
|
+
kind: effect.kind,
|
|
64
|
+
runs: effect.runs,
|
|
65
|
+
input,
|
|
66
|
+
where,
|
|
67
|
+
status: null,
|
|
68
|
+
headers: [],
|
|
69
|
+
};
|
|
70
|
+
const done = (result, tags, refreshed) => ({
|
|
71
|
+
...base,
|
|
72
|
+
ok: result.ok,
|
|
73
|
+
error: result.ok ? null : result.error,
|
|
74
|
+
value: result.ok ? result.value : result.data,
|
|
75
|
+
ms: Math.round(performance.now() - started),
|
|
76
|
+
tags,
|
|
77
|
+
refreshed,
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
if (hook?.invoke && hook.query) {
|
|
81
|
+
if (effect.kind === 'mutation') {
|
|
82
|
+
const { result, tags } = await hook.invoke(effect.ref, input);
|
|
83
|
+
return done(result, tags, true);
|
|
84
|
+
}
|
|
85
|
+
return done(await hook.query(effect.ref, input), [], false);
|
|
86
|
+
}
|
|
87
|
+
if (effect.runs === 'browser')
|
|
88
|
+
return done({
|
|
89
|
+
ok: false,
|
|
90
|
+
error: 'Unexpected',
|
|
91
|
+
data: { message: 'This page has no client: browser-run effects need one' },
|
|
92
|
+
}, [], false);
|
|
93
|
+
const query = effect.kind === 'query';
|
|
94
|
+
const response = await win.fetch(query ? '/_hozu/query' : '/_hozu/effect', {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { 'content-type': 'application/json' },
|
|
97
|
+
body: JSON.stringify(query ? { query: effect.ref, input } : { effect: effect.ref, input, keys: [] }),
|
|
98
|
+
});
|
|
99
|
+
if (!response.ok)
|
|
100
|
+
return done({ ok: false, error: 'Unexpected', data: { message: await response.text() } }, [], false);
|
|
101
|
+
const body = (await response.json());
|
|
102
|
+
return {
|
|
103
|
+
...done((query ? body : body.result), body.tags ?? [], false),
|
|
104
|
+
where: 'server',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
return done({ ok: false, error: 'Unexpected', data: { message: String(error) } }, [], false);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const parsed = (text) => {
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(text);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return text;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const paramsOf = (path) => [...path.matchAll(/:([A-Za-z_]\w*)/g)].map((m) => m[1]);
|
|
120
|
+
/** Sends a request to a declared endpoint, as a client of the app would (cookies included). */
|
|
121
|
+
async function send(endpoint, request, win) {
|
|
122
|
+
const started = performance.now();
|
|
123
|
+
let path = endpoint.path;
|
|
124
|
+
for (const [name, value] of Object.entries(request.params))
|
|
125
|
+
path = path.replace(`:${name}`, encodeURIComponent(value));
|
|
126
|
+
const url = new URL(path, win.location.origin);
|
|
127
|
+
let body = null;
|
|
128
|
+
const headers = new Headers(request.headers.filter(([k]) => k.trim()));
|
|
129
|
+
if (endpoint.method === 'GET') {
|
|
130
|
+
try {
|
|
131
|
+
const query = JSON.parse(request.body || '{}');
|
|
132
|
+
for (const [k, v] of Object.entries(query))
|
|
133
|
+
for (const one of Array.isArray(v) ? v : [v])
|
|
134
|
+
url.searchParams.append(k, String(one));
|
|
135
|
+
}
|
|
136
|
+
catch { }
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
body = request.body;
|
|
140
|
+
if (!headers.has('content-type') && !endpoint.raw)
|
|
141
|
+
headers.set('content-type', 'application/json');
|
|
142
|
+
}
|
|
143
|
+
const base = {
|
|
144
|
+
ref: endpoint.ref,
|
|
145
|
+
kind: 'endpoint',
|
|
146
|
+
runs: 'server',
|
|
147
|
+
input: parsed(request.body),
|
|
148
|
+
where: 'server',
|
|
149
|
+
tags: [],
|
|
150
|
+
refreshed: false,
|
|
151
|
+
url: url.href,
|
|
152
|
+
};
|
|
153
|
+
try {
|
|
154
|
+
const response = await win.fetch(url, { method: endpoint.method, headers, body, redirect: 'manual' });
|
|
155
|
+
if (response.type === 'opaqueredirect')
|
|
156
|
+
return {
|
|
157
|
+
...base,
|
|
158
|
+
ok: true,
|
|
159
|
+
error: null,
|
|
160
|
+
value: 'A redirect (the browser does not show where to; open the endpoint URL to follow it)',
|
|
161
|
+
ms: Math.round(performance.now() - started),
|
|
162
|
+
status: 0,
|
|
163
|
+
headers: [],
|
|
164
|
+
};
|
|
165
|
+
const text = await response.text();
|
|
166
|
+
let value = text;
|
|
167
|
+
try {
|
|
168
|
+
value = JSON.parse(text);
|
|
169
|
+
}
|
|
170
|
+
catch { }
|
|
171
|
+
return {
|
|
172
|
+
...base,
|
|
173
|
+
ok: response.ok,
|
|
174
|
+
error: response.ok ? null : `HTTP ${response.status}`,
|
|
175
|
+
value,
|
|
176
|
+
ms: Math.round(performance.now() - started),
|
|
177
|
+
status: response.status,
|
|
178
|
+
headers: [...response.headers],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
return {
|
|
183
|
+
...base,
|
|
184
|
+
ok: false,
|
|
185
|
+
error: 'Unexpected',
|
|
186
|
+
value: String(error),
|
|
187
|
+
ms: Math.round(performance.now() - started),
|
|
188
|
+
status: null,
|
|
189
|
+
headers: [],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export const hozuCall = (r, scope) => `npx hozu call ${r.ref} --input '${JSON.stringify(r.input).replace(/'/g, "'\\''")}'${scope === 'user' ? ` --session '<the session JSON>'` : ''}${r.kind === 'mutation' ? ' --write' : ''}`;
|
|
194
|
+
const KEY = `hozu-devtools-api:${location.host}`;
|
|
195
|
+
/** A record saved by this or an older DevTools, with every field present; null when it is not one. */
|
|
196
|
+
const normal = (r) => {
|
|
197
|
+
const o = r;
|
|
198
|
+
if (!o || typeof o.ref !== 'string' || typeof o.id !== 'number')
|
|
199
|
+
return null;
|
|
200
|
+
return {
|
|
201
|
+
kind: 'query',
|
|
202
|
+
runs: 'server',
|
|
203
|
+
input: null,
|
|
204
|
+
ok: false,
|
|
205
|
+
error: null,
|
|
206
|
+
value: null,
|
|
207
|
+
ms: 0,
|
|
208
|
+
where: 'server',
|
|
209
|
+
tags: [],
|
|
210
|
+
refreshed: false,
|
|
211
|
+
status: null,
|
|
212
|
+
headers: [],
|
|
213
|
+
requests: [],
|
|
214
|
+
at: o.id,
|
|
215
|
+
...o,
|
|
216
|
+
};
|
|
217
|
+
};
|
|
218
|
+
export function drawer(host) {
|
|
219
|
+
const saved = read(KEY, {});
|
|
220
|
+
const ui = {
|
|
221
|
+
tab: saved.tab ?? 'query',
|
|
222
|
+
view: saved.view ?? 'table',
|
|
223
|
+
height: saved.height ?? 340,
|
|
224
|
+
open: saved.open ?? false,
|
|
225
|
+
history: (Array.isArray(saved.history) ? saved.history : []).flatMap((r) => normal(r) ?? []),
|
|
226
|
+
};
|
|
227
|
+
if (!['query', 'mutation', 'endpoint', 'history'].includes(ui.tab))
|
|
228
|
+
ui.tab = 'query';
|
|
229
|
+
const persist = () => write(KEY, { ...ui, history: ui.history.slice(0, 50) });
|
|
230
|
+
let list = null;
|
|
231
|
+
let endpoints = [];
|
|
232
|
+
let session = null;
|
|
233
|
+
let sessionOpen = false;
|
|
234
|
+
let sessionError = null;
|
|
235
|
+
let selected = ui.history[0]?.id ?? null;
|
|
236
|
+
let confirming = null;
|
|
237
|
+
const inputs = new Map();
|
|
238
|
+
const asJson = new Set();
|
|
239
|
+
const requests = new Map();
|
|
240
|
+
const running = new Set();
|
|
241
|
+
const el = h('section', { class: 'api', 'aria-label': 'API', hidden: true });
|
|
242
|
+
let shown = false;
|
|
243
|
+
const height = () => (shown ? Math.min(ui.height, innerHeight - 120) : 0);
|
|
244
|
+
const resize = () => {
|
|
245
|
+
el.style.height = `${height()}px`;
|
|
246
|
+
host.resized();
|
|
247
|
+
};
|
|
248
|
+
const where = (runs) => host.plain() ? { server: 'server', either: 'server, then browser', browser: 'browser' }[runs] : runs;
|
|
249
|
+
const remember = (outcome, sent) => {
|
|
250
|
+
const record = { ...outcome, requests: sent, id: Date.now(), at: Date.now() };
|
|
251
|
+
ui.history.unshift(record);
|
|
252
|
+
ui.history = ui.history.slice(0, 50);
|
|
253
|
+
selected = record.id;
|
|
254
|
+
persist();
|
|
255
|
+
};
|
|
256
|
+
async function run(effect) {
|
|
257
|
+
const input = inputs.get(effect.ref) ?? startInput(effect, host.win().document);
|
|
258
|
+
running.add(effect.ref);
|
|
259
|
+
confirming = null;
|
|
260
|
+
draw();
|
|
261
|
+
const { value, requests: sent } = await traced(host.win(), () => call(effect, input, host.win()));
|
|
262
|
+
running.delete(effect.ref);
|
|
263
|
+
remember(value, sent);
|
|
264
|
+
draw();
|
|
265
|
+
}
|
|
266
|
+
async function sendTo(endpoint) {
|
|
267
|
+
running.add(endpoint.ref);
|
|
268
|
+
draw();
|
|
269
|
+
const { value, requests: sent } = await traced(host.win(), () => send(endpoint, requestOf(endpoint), host.win()));
|
|
270
|
+
running.delete(endpoint.ref);
|
|
271
|
+
const { url: _, ...outcome } = value;
|
|
272
|
+
remember(outcome, sent.filter((r) => r.side === 'server'));
|
|
273
|
+
draw();
|
|
274
|
+
}
|
|
275
|
+
const requestOf = (endpoint) => {
|
|
276
|
+
let r = requests.get(endpoint.ref);
|
|
277
|
+
if (!r) {
|
|
278
|
+
r = {
|
|
279
|
+
params: Object.fromEntries(paramsOf(endpoint.path).map((p) => [p, ''])),
|
|
280
|
+
body: endpoint.raw ? '' : JSON.stringify(example(endpoint.input) ?? {}, null, 2),
|
|
281
|
+
headers: [['', '']],
|
|
282
|
+
};
|
|
283
|
+
requests.set(endpoint.ref, r);
|
|
284
|
+
}
|
|
285
|
+
return r;
|
|
286
|
+
};
|
|
287
|
+
const invalidOf = (last) => last && !last.ok && last.error === 'Invalid'
|
|
288
|
+
? (last.value?.fields ?? {})
|
|
289
|
+
: {};
|
|
290
|
+
const jsonEditor = (label, value, onValid, rows = 5) => {
|
|
291
|
+
const area = h('textarea', { class: 'api-json-input', rows: String(rows), spellcheck: 'false', 'aria-label': label }, [value]);
|
|
292
|
+
area.addEventListener('input', () => {
|
|
293
|
+
try {
|
|
294
|
+
JSON.parse(area.value);
|
|
295
|
+
area.removeAttribute('aria-invalid');
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
area.setAttribute('aria-invalid', 'true');
|
|
299
|
+
}
|
|
300
|
+
onValid(area.value);
|
|
301
|
+
});
|
|
302
|
+
return area;
|
|
303
|
+
};
|
|
304
|
+
const fieldsOf = (effect, last) => {
|
|
305
|
+
const schema = effect.input;
|
|
306
|
+
const props = (schema.properties ?? {});
|
|
307
|
+
const value = (inputs.get(effect.ref) ?? startInput(effect, host.win().document));
|
|
308
|
+
const invalid = invalidOf(last);
|
|
309
|
+
if (!flat(schema) || asJson.has(effect.ref))
|
|
310
|
+
return h('div', { class: 'api-fields' }, [
|
|
311
|
+
jsonEditor(`${effect.ref} input (JSON)`, JSON.stringify(value, null, 2), (text) => {
|
|
312
|
+
try {
|
|
313
|
+
inputs.set(effect.ref, JSON.parse(text));
|
|
314
|
+
}
|
|
315
|
+
catch { }
|
|
316
|
+
}),
|
|
317
|
+
Object.keys(invalid).length
|
|
318
|
+
? h('em', { class: 'api-invalid' }, Object.entries(invalid)
|
|
319
|
+
.filter(([, m]) => m)
|
|
320
|
+
.map(([k, m]) => `${k}: ${m}. `))
|
|
321
|
+
: null,
|
|
322
|
+
]);
|
|
323
|
+
if (!Object.keys(props).length)
|
|
324
|
+
return h('span', { class: 'api-none' }, ['no input']);
|
|
325
|
+
const current = { ...value };
|
|
326
|
+
return h('div', { class: 'api-fields' }, Object.entries(props).map(([name, s]) => {
|
|
327
|
+
const id = `api-${effect.ref}-${name}`;
|
|
328
|
+
const control = Array.isArray(s.enum)
|
|
329
|
+
? h('select', { id }, s.enum.map((o) => h('option', { value: String(o), selected: current[name] === o }, [String(o)])))
|
|
330
|
+
: h('input', {
|
|
331
|
+
id,
|
|
332
|
+
type: s.type === 'boolean'
|
|
333
|
+
? 'checkbox'
|
|
334
|
+
: s.type === 'number' || s.type === 'integer'
|
|
335
|
+
? 'number'
|
|
336
|
+
: 'text',
|
|
337
|
+
placeholder: s.type === 'string' ? name : '',
|
|
338
|
+
...(s.type === 'boolean'
|
|
339
|
+
? { checked: current[name] === true }
|
|
340
|
+
: { value: String(current[name] ?? '') }),
|
|
341
|
+
'aria-invalid': invalid[name] ? 'true' : undefined,
|
|
342
|
+
});
|
|
343
|
+
const update = () => {
|
|
344
|
+
const c = control;
|
|
345
|
+
current[name] =
|
|
346
|
+
s.type === 'boolean'
|
|
347
|
+
? c.checked
|
|
348
|
+
: s.type === 'number' || s.type === 'integer'
|
|
349
|
+
? Number(c.value)
|
|
350
|
+
: c.value;
|
|
351
|
+
inputs.set(effect.ref, { ...current });
|
|
352
|
+
};
|
|
353
|
+
control.addEventListener('input', update);
|
|
354
|
+
control.addEventListener('change', update);
|
|
355
|
+
control.addEventListener('keydown', (e) => {
|
|
356
|
+
if (e.key === 'Enter') {
|
|
357
|
+
e.preventDefault();
|
|
358
|
+
update();
|
|
359
|
+
ask(effect);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
return h('label', { class: 'api-field', for: id }, [
|
|
363
|
+
h('span', {}, [name]),
|
|
364
|
+
control,
|
|
365
|
+
invalid[name] ? h('em', { class: 'api-invalid' }, [invalid[name]]) : null,
|
|
366
|
+
]);
|
|
367
|
+
}));
|
|
368
|
+
};
|
|
369
|
+
const ask = (effect) => {
|
|
370
|
+
if (effect.kind === 'mutation' && confirming !== effect.ref) {
|
|
371
|
+
confirming = effect.ref;
|
|
372
|
+
return draw();
|
|
373
|
+
}
|
|
374
|
+
void run(effect);
|
|
375
|
+
};
|
|
376
|
+
const row = (effect) => {
|
|
377
|
+
const last = ui.history.find((r) => r.ref === effect.ref);
|
|
378
|
+
const busy = running.has(effect.ref);
|
|
379
|
+
const asking = confirming === effect.ref;
|
|
380
|
+
const canForm = flat(effect.input) && Object.keys(effect.input.properties ?? {}).length > 0;
|
|
381
|
+
return h('div', { class: `api-row${last && last.id === selected ? ' on' : ''}`, 'data-effect': effect.ref }, [
|
|
382
|
+
h('span', { class: `api-dot ${last ? (last.ok ? 'ok' : 'bad') : ''}`, 'aria-hidden': 'true' }),
|
|
383
|
+
h('div', { class: 'api-name' }, [
|
|
384
|
+
h('b', {}, [host.plain() ? effect.label : effect.ref]),
|
|
385
|
+
h('span', { class: `api-kind ${effect.kind === 'query' ? 'read' : 'write'}` }, [
|
|
386
|
+
effect.kind === 'query' ? 'read' : 'write',
|
|
387
|
+
]),
|
|
388
|
+
h('span', { class: `api-runs ${effect.runs}`, title: `runs: ${effect.runs}` }, [
|
|
389
|
+
where(effect.runs),
|
|
390
|
+
]),
|
|
391
|
+
effect.kind === 'query' ? h('span', { class: 'api-tag' }, [effect.freshness]) : null,
|
|
392
|
+
effect.implemented
|
|
393
|
+
? h('span', { class: 'api-where', title: 'Where it is implemented' }, [
|
|
394
|
+
`${effect.implemented.file}:${effect.implemented.line}`,
|
|
395
|
+
])
|
|
396
|
+
: null,
|
|
397
|
+
canForm
|
|
398
|
+
? h('button', {
|
|
399
|
+
type: 'button',
|
|
400
|
+
class: 'api-switch',
|
|
401
|
+
title: 'JSON sends any value, also one the schema rejects',
|
|
402
|
+
onclick: () => {
|
|
403
|
+
if (asJson.has(effect.ref))
|
|
404
|
+
asJson.delete(effect.ref);
|
|
405
|
+
else
|
|
406
|
+
asJson.add(effect.ref);
|
|
407
|
+
draw();
|
|
408
|
+
},
|
|
409
|
+
}, [asJson.has(effect.ref) ? 'Form' : 'JSON'])
|
|
410
|
+
: null,
|
|
411
|
+
]),
|
|
412
|
+
fieldsOf(effect, last),
|
|
413
|
+
asking
|
|
414
|
+
? h('div', { class: 'api-confirm', role: 'alert' }, [
|
|
415
|
+
h('span', {}, ['Writes your development data.']),
|
|
416
|
+
h('button', {
|
|
417
|
+
type: 'button',
|
|
418
|
+
class: 'api-ghost',
|
|
419
|
+
onclick: () => {
|
|
420
|
+
confirming = null;
|
|
421
|
+
draw();
|
|
422
|
+
},
|
|
423
|
+
}, ['Cancel']),
|
|
424
|
+
h('button', { type: 'button', class: 'api-run warn', onclick: () => void run(effect) }, [
|
|
425
|
+
'Run',
|
|
426
|
+
]),
|
|
427
|
+
])
|
|
428
|
+
: h('button', {
|
|
429
|
+
type: 'button',
|
|
430
|
+
class: `api-run${effect.kind === 'mutation' ? ' warn' : ''}`,
|
|
431
|
+
disabled: busy,
|
|
432
|
+
onclick: () => ask(effect),
|
|
433
|
+
}, [busy ? 'Running…' : 'Run ▶']),
|
|
434
|
+
]);
|
|
435
|
+
};
|
|
436
|
+
const endpointRow = (endpoint) => {
|
|
437
|
+
const r = requestOf(endpoint);
|
|
438
|
+
const last = ui.history.find((x) => x.ref === endpoint.ref);
|
|
439
|
+
const busy = running.has(endpoint.ref);
|
|
440
|
+
const headerRows = r.headers.map((pair, i) => {
|
|
441
|
+
const key = h('input', {
|
|
442
|
+
value: pair[0],
|
|
443
|
+
placeholder: 'Header',
|
|
444
|
+
'aria-label': 'Header name',
|
|
445
|
+
});
|
|
446
|
+
const val = h('input', {
|
|
447
|
+
value: pair[1],
|
|
448
|
+
placeholder: 'Value',
|
|
449
|
+
'aria-label': 'Header value',
|
|
450
|
+
});
|
|
451
|
+
key.addEventListener('input', () => {
|
|
452
|
+
r.headers[i] = [key.value, val.value];
|
|
453
|
+
});
|
|
454
|
+
val.addEventListener('input', () => {
|
|
455
|
+
r.headers[i] = [key.value, val.value];
|
|
456
|
+
});
|
|
457
|
+
return h('div', { class: 'api-header' }, [key, val]);
|
|
458
|
+
});
|
|
459
|
+
return h('div', {
|
|
460
|
+
class: `api-row endpoint${last && last.id === selected ? ' on' : ''}`,
|
|
461
|
+
'data-endpoint': endpoint.ref,
|
|
462
|
+
}, [
|
|
463
|
+
h('span', { class: `api-dot ${last ? (last.ok ? 'ok' : 'bad') : ''}`, 'aria-hidden': 'true' }),
|
|
464
|
+
h('div', { class: 'api-name' }, [
|
|
465
|
+
h('span', { class: `api-method ${endpoint.method.toLowerCase()}` }, [endpoint.method]),
|
|
466
|
+
h('b', {}, [endpoint.path]),
|
|
467
|
+
h('span', { class: 'api-tag' }, [host.plain() ? endpoint.ref.split('.').pop() : endpoint.ref]),
|
|
468
|
+
]),
|
|
469
|
+
h('div', { class: 'api-fields' }, [
|
|
470
|
+
...Object.keys(r.params).map((p) => {
|
|
471
|
+
const input = h('input', {
|
|
472
|
+
value: r.params[p],
|
|
473
|
+
placeholder: p,
|
|
474
|
+
'aria-label': `Path :${p}`,
|
|
475
|
+
});
|
|
476
|
+
input.addEventListener('input', () => {
|
|
477
|
+
r.params[p] = input.value;
|
|
478
|
+
});
|
|
479
|
+
return h('label', { class: 'api-field' }, [h('span', {}, [`:${p}`]), input]);
|
|
480
|
+
}),
|
|
481
|
+
h('div', { class: 'api-field wide' }, [
|
|
482
|
+
h('span', {}, [
|
|
483
|
+
endpoint.method === 'GET' ? 'Query (JSON)' : endpoint.raw ? 'Body' : 'Body (JSON)',
|
|
484
|
+
]),
|
|
485
|
+
jsonEditor(`${endpoint.ref} body`, r.body, (text) => {
|
|
486
|
+
r.body = text;
|
|
487
|
+
}),
|
|
488
|
+
]),
|
|
489
|
+
h('div', { class: 'api-field wide' }, [
|
|
490
|
+
h('span', {}, ['Headers']),
|
|
491
|
+
...headerRows,
|
|
492
|
+
h('button', {
|
|
493
|
+
type: 'button',
|
|
494
|
+
class: 'api-ghost',
|
|
495
|
+
onclick: () => {
|
|
496
|
+
r.headers.push(['', '']);
|
|
497
|
+
draw();
|
|
498
|
+
},
|
|
499
|
+
}, ['+ Header']),
|
|
500
|
+
]),
|
|
501
|
+
]),
|
|
502
|
+
h('button', { type: 'button', class: 'api-run', disabled: busy, onclick: () => void sendTo(endpoint) }, [busy ? 'Sending…' : 'Send ▶']),
|
|
503
|
+
]);
|
|
504
|
+
};
|
|
505
|
+
const cell = (v) => v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
506
|
+
function table(value) {
|
|
507
|
+
if (Array.isArray(value) &&
|
|
508
|
+
value.length &&
|
|
509
|
+
value.every((r) => r && typeof r === 'object' && !Array.isArray(r))) {
|
|
510
|
+
const columns = [...new Set(value.flatMap((r) => Object.keys(r)))].slice(0, 8);
|
|
511
|
+
return h('table', { class: 'api-table' }, [
|
|
512
|
+
h('thead', {}, [
|
|
513
|
+
h('tr', {}, columns.map((c) => h('th', {}, [c]))),
|
|
514
|
+
]),
|
|
515
|
+
h('tbody', {}, value.map((r) => h('tr', {}, columns.map((c) => h('td', {}, [cell(r[c])]))))),
|
|
516
|
+
]);
|
|
517
|
+
}
|
|
518
|
+
if (value && typeof value === 'object' && !Array.isArray(value))
|
|
519
|
+
return h('table', { class: 'api-table kv' }, [
|
|
520
|
+
h('tbody', {}, Object.entries(value).map(([k, v]) => h('tr', {}, [h('th', {}, [k]), h('td', {}, [cell(v)])]))),
|
|
521
|
+
]);
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
const copyButton = (label, text, disabled = false, title = '') => {
|
|
525
|
+
const button = h('button', {
|
|
526
|
+
type: 'button',
|
|
527
|
+
class: 'api-ghost',
|
|
528
|
+
disabled,
|
|
529
|
+
title,
|
|
530
|
+
onclick: async () => {
|
|
531
|
+
await navigator.clipboard.writeText(text()).catch(() => { });
|
|
532
|
+
button.textContent = 'Copied';
|
|
533
|
+
},
|
|
534
|
+
}, [label]);
|
|
535
|
+
return button;
|
|
536
|
+
};
|
|
537
|
+
const sentList = (r) => r.requests.length
|
|
538
|
+
? h('div', { class: 'api-sent' }, [
|
|
539
|
+
h('div', { class: 'api-label' }, ['Requests it sent']),
|
|
540
|
+
...r.requests.map((t) => h('details', { class: 'api-request' }, [
|
|
541
|
+
h('summary', {}, [
|
|
542
|
+
h('span', { class: `api-method ${t.method.toLowerCase()}` }, [t.method]),
|
|
543
|
+
h('code', {}, [t.url]),
|
|
544
|
+
h('span', { class: `api-code ${t.status && t.status < 400 ? 'ok' : 'bad'}` }, [
|
|
545
|
+
t.error ? 'failed' : String(t.status),
|
|
546
|
+
]),
|
|
547
|
+
h('span', { class: 'api-tag' }, [
|
|
548
|
+
`${t.ms} ms · ${t.side === 'server' ? 'from the server' : 'from this browser'}`,
|
|
549
|
+
]),
|
|
550
|
+
]),
|
|
551
|
+
h('div', { class: 'api-request-body' }, [
|
|
552
|
+
t.error ? h('em', { class: 'api-invalid' }, [t.error]) : null,
|
|
553
|
+
h('b', {}, ['Request headers']),
|
|
554
|
+
h('pre', {}, [t.requestHeaders.map(([k, v]) => `${k}: ${v}`).join('\n') || '(none)']),
|
|
555
|
+
t.requestBody ? h('b', {}, ['Request body']) : null,
|
|
556
|
+
t.requestBody ? h('pre', {}, [t.requestBody]) : null,
|
|
557
|
+
h('b', {}, ['Response headers']),
|
|
558
|
+
h('pre', {}, [t.responseHeaders.map(([k, v]) => `${k}: ${v}`).join('\n') || '(none)']),
|
|
559
|
+
t.responseBody ? h('b', {}, ['Response body']) : null,
|
|
560
|
+
t.responseBody ? h('pre', {}, [t.responseBody]) : null,
|
|
561
|
+
copyButton('Copy as curl', () => curl(t)),
|
|
562
|
+
]),
|
|
563
|
+
])),
|
|
564
|
+
])
|
|
565
|
+
: null;
|
|
566
|
+
function result(r) {
|
|
567
|
+
if (!r)
|
|
568
|
+
return h('div', { class: 'api-empty' }, ['Run something to see its answer here.']);
|
|
569
|
+
const effect = list?.find((e) => e.ref === r.ref);
|
|
570
|
+
const status = r.kind === 'endpoint'
|
|
571
|
+
? `${r.status === 0 ? 'Redirect' : (r.status ?? 'No answer')}${r.ok ? '' : ` · ${r.error}`}`
|
|
572
|
+
: r.ok
|
|
573
|
+
? 'OK'
|
|
574
|
+
: r.error === 'Unexpected'
|
|
575
|
+
? 'Unexpected error'
|
|
576
|
+
: `Declared error: ${r.error}`;
|
|
577
|
+
const grid = typeof r.value === 'object' ? table(r.value) : null;
|
|
578
|
+
return h('div', { class: 'api-result' }, [
|
|
579
|
+
h('div', { class: `api-status ${r.ok ? 'ok' : 'bad'}`, role: 'status' }, [
|
|
580
|
+
h('b', {}, [status]),
|
|
581
|
+
` · ${r.ms} ms · ${r.kind === 'endpoint' ? 'HTTP' : r.where === 'server' ? 'through the server' : 'in this browser'} · ${host.plain() ? (effect?.label ?? r.ref) : r.ref}`,
|
|
582
|
+
]),
|
|
583
|
+
r.tags.length
|
|
584
|
+
? h('div', { class: 'api-note' }, [
|
|
585
|
+
`Invalidated ${r.tags.join(', ')}`,
|
|
586
|
+
r.refreshed
|
|
587
|
+
? ' · the page re-read it'
|
|
588
|
+
: h('button', { type: 'button', class: 'api-link', onclick: () => host.win().location.reload() }, [' · reload the page to see it']),
|
|
589
|
+
])
|
|
590
|
+
: null,
|
|
591
|
+
h('div', { class: 'api-tools' }, [
|
|
592
|
+
h('div', { class: 'api-seg', role: 'group', 'aria-label': 'View' }, [
|
|
593
|
+
...['table', 'json'].map((v) => h('button', {
|
|
594
|
+
type: 'button',
|
|
595
|
+
'aria-pressed': ui.view === v ? 'true' : 'false',
|
|
596
|
+
disabled: v === 'table' && !grid,
|
|
597
|
+
onclick: () => {
|
|
598
|
+
ui.view = v;
|
|
599
|
+
persist();
|
|
600
|
+
draw();
|
|
601
|
+
},
|
|
602
|
+
}, [v === 'table' ? 'Table' : 'JSON'])),
|
|
603
|
+
]),
|
|
604
|
+
r.kind === 'endpoint'
|
|
605
|
+
? null
|
|
606
|
+
: copyButton('Copy as hozu call', () => hozuCall(r, effect?.scope ?? 'public'), r.runs === 'browser', r.runs === 'browser'
|
|
607
|
+
? 'hozu call runs server and either effects; this one needs the browser'
|
|
608
|
+
: ''),
|
|
609
|
+
]),
|
|
610
|
+
ui.view === 'table' && grid
|
|
611
|
+
? h('div', { class: 'api-scroll' }, [grid])
|
|
612
|
+
: h('pre', { class: 'api-scroll api-pre' }, [
|
|
613
|
+
typeof r.value === 'string' ? r.value : (JSON.stringify(r.value, null, 2) ?? 'undefined'),
|
|
614
|
+
]),
|
|
615
|
+
r.kind === 'endpoint' && r.headers.length
|
|
616
|
+
? h('details', { class: 'api-request' }, [
|
|
617
|
+
h('summary', {}, ['Response headers']),
|
|
618
|
+
h('pre', {}, [r.headers.map(([k, v]) => `${k}: ${v}`).join('\n')]),
|
|
619
|
+
])
|
|
620
|
+
: null,
|
|
621
|
+
sentList(r),
|
|
622
|
+
]);
|
|
623
|
+
}
|
|
624
|
+
function historyList() {
|
|
625
|
+
if (!ui.history.length)
|
|
626
|
+
return [h('div', { class: 'api-empty' }, ['No calls yet.'])];
|
|
627
|
+
return ui.history.map((r) => h('div', { class: `api-row history${r.id === selected ? ' on' : ''}` }, [
|
|
628
|
+
h('span', { class: `api-dot ${r.ok ? 'ok' : 'bad'}`, 'aria-hidden': 'true' }),
|
|
629
|
+
h('button', {
|
|
630
|
+
type: 'button',
|
|
631
|
+
class: 'api-open',
|
|
632
|
+
onclick: () => {
|
|
633
|
+
selected = r.id;
|
|
634
|
+
draw();
|
|
635
|
+
},
|
|
636
|
+
}, [
|
|
637
|
+
h('b', {}, [r.ref]),
|
|
638
|
+
h('span', { class: 'api-tag' }, [new Date(r.at).toLocaleTimeString()]),
|
|
639
|
+
h('span', { class: 'api-tag' }, [`${r.ms} ms`]),
|
|
640
|
+
h('code', {}, [JSON.stringify(r.input)]),
|
|
641
|
+
]),
|
|
642
|
+
r.kind === 'endpoint'
|
|
643
|
+
? null
|
|
644
|
+
: h('button', {
|
|
645
|
+
type: 'button',
|
|
646
|
+
class: 'api-ghost',
|
|
647
|
+
onclick: () => {
|
|
648
|
+
const effect = list?.find((e) => e.ref === r.ref);
|
|
649
|
+
if (!effect)
|
|
650
|
+
return;
|
|
651
|
+
inputs.set(r.ref, r.input);
|
|
652
|
+
ui.tab = effect.kind;
|
|
653
|
+
ask(effect);
|
|
654
|
+
},
|
|
655
|
+
}, ['Run again']),
|
|
656
|
+
]));
|
|
657
|
+
}
|
|
658
|
+
function sessionBox() {
|
|
659
|
+
if (!session?.declared || !sessionOpen)
|
|
660
|
+
return null;
|
|
661
|
+
let text = JSON.stringify(session.current ?? example(session.schema), null, 2);
|
|
662
|
+
const apply = async (value) => {
|
|
663
|
+
const response = await fetch('/_hozu/dev/session', {
|
|
664
|
+
method: 'POST',
|
|
665
|
+
headers: { 'content-type': 'application/json' },
|
|
666
|
+
body: JSON.stringify({ session: value }),
|
|
667
|
+
});
|
|
668
|
+
if (!response.ok) {
|
|
669
|
+
sessionError =
|
|
670
|
+
(await response.json().catch(() => ({}))).error ?? response.statusText;
|
|
671
|
+
return draw();
|
|
672
|
+
}
|
|
673
|
+
sessionError = null;
|
|
674
|
+
sessionOpen = false;
|
|
675
|
+
host.win().location.reload();
|
|
676
|
+
};
|
|
677
|
+
return h('div', { class: 'api-row session' }, [
|
|
678
|
+
h('span', {}),
|
|
679
|
+
h('div', { class: 'api-name' }, [
|
|
680
|
+
h('b', {}, ['Act as']),
|
|
681
|
+
h('span', { class: 'api-tag' }, ['development only: sets this browser’s session cookie']),
|
|
682
|
+
]),
|
|
683
|
+
h('div', { class: 'api-fields' }, [
|
|
684
|
+
jsonEditor('Session (JSON)', text, (t) => {
|
|
685
|
+
text = t;
|
|
686
|
+
}),
|
|
687
|
+
sessionError ? h('em', { class: 'api-invalid' }, [sessionError]) : null,
|
|
688
|
+
]),
|
|
689
|
+
h('div', { class: 'api-confirm' }, [
|
|
690
|
+
h('button', { type: 'button', class: 'api-ghost', onclick: () => void apply(null) }, ['Sign out']),
|
|
691
|
+
h('button', {
|
|
692
|
+
type: 'button',
|
|
693
|
+
class: 'api-run',
|
|
694
|
+
onclick: () => {
|
|
695
|
+
try {
|
|
696
|
+
void apply(JSON.parse(text));
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
sessionError = 'The session is not valid JSON';
|
|
700
|
+
draw();
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
}, ['Act as']),
|
|
704
|
+
]),
|
|
705
|
+
]);
|
|
706
|
+
}
|
|
707
|
+
function draw() {
|
|
708
|
+
try {
|
|
709
|
+
paint();
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
el.replaceChildren(h('div', { class: 'api-empty', role: 'alert' }, [
|
|
713
|
+
`The API drawer could not draw: ${error instanceof Error ? error.message : String(error)}. `,
|
|
714
|
+
h('button', {
|
|
715
|
+
type: 'button',
|
|
716
|
+
class: 'api-link',
|
|
717
|
+
onclick: () => {
|
|
718
|
+
ui.history = [];
|
|
719
|
+
selected = null;
|
|
720
|
+
persist();
|
|
721
|
+
draw();
|
|
722
|
+
},
|
|
723
|
+
}, ['Clear its saved state']),
|
|
724
|
+
]));
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
function paint() {
|
|
728
|
+
const queries = (list ?? []).filter((e) => e.kind === 'query');
|
|
729
|
+
const mutations = (list ?? []).filter((e) => e.kind === 'mutation');
|
|
730
|
+
const tab = (id, label, count) => h('button', {
|
|
731
|
+
type: 'button',
|
|
732
|
+
role: 'tab',
|
|
733
|
+
'aria-selected': ui.tab === id ? 'true' : 'false',
|
|
734
|
+
onclick: () => {
|
|
735
|
+
ui.tab = id;
|
|
736
|
+
persist();
|
|
737
|
+
draw();
|
|
738
|
+
},
|
|
739
|
+
}, [label, h('span', { class: 'count' }, [String(count)])]);
|
|
740
|
+
const grip = h('div', { class: 'api-grip', title: 'Drag to resize', 'aria-hidden': 'true' });
|
|
741
|
+
grip.addEventListener('pointerdown', (event) => {
|
|
742
|
+
event.preventDefault();
|
|
743
|
+
grip.setPointerCapture(event.pointerId);
|
|
744
|
+
const move = (e) => {
|
|
745
|
+
ui.height = Math.max(160, Math.min(innerHeight - 120, innerHeight - e.clientY));
|
|
746
|
+
resize();
|
|
747
|
+
};
|
|
748
|
+
const up = () => {
|
|
749
|
+
grip.removeEventListener('pointermove', move);
|
|
750
|
+
grip.removeEventListener('pointerup', up);
|
|
751
|
+
persist();
|
|
752
|
+
};
|
|
753
|
+
grip.addEventListener('pointermove', move);
|
|
754
|
+
grip.addEventListener('pointerup', up);
|
|
755
|
+
});
|
|
756
|
+
const rows = ui.tab === 'history'
|
|
757
|
+
? historyList()
|
|
758
|
+
: ui.tab === 'endpoint'
|
|
759
|
+
? endpoints.map(endpointRow)
|
|
760
|
+
: list === null
|
|
761
|
+
? [h('div', { class: 'api-empty' }, ['This page is not a Hozu page.'])]
|
|
762
|
+
: (ui.tab === 'query' ? queries : mutations).map(row);
|
|
763
|
+
const who = session?.declared &&
|
|
764
|
+
h('button', {
|
|
765
|
+
type: 'button',
|
|
766
|
+
class: `api-session${sessionOpen ? ' on' : ''}`,
|
|
767
|
+
title: 'The session this browser sends; act as someone else',
|
|
768
|
+
onclick: () => {
|
|
769
|
+
sessionOpen = !sessionOpen;
|
|
770
|
+
draw();
|
|
771
|
+
},
|
|
772
|
+
}, [session.current === null ? 'Signed out' : `As ${JSON.stringify(session.current)}`]);
|
|
773
|
+
el.replaceChildren(grip, h('div', { class: 'api-head' }, [
|
|
774
|
+
h('b', { class: 'api-title' }, ['API']),
|
|
775
|
+
h('div', { class: 'api-tabs', role: 'tablist' }, [
|
|
776
|
+
tab('query', host.plain() ? 'Reads' : 'Queries', queries.length),
|
|
777
|
+
tab('mutation', host.plain() ? 'Changes' : 'Mutations', mutations.length),
|
|
778
|
+
endpoints.length ? tab('endpoint', 'Endpoints', endpoints.length) : null,
|
|
779
|
+
tab('history', 'History', ui.history.length),
|
|
780
|
+
]),
|
|
781
|
+
h('span', { class: 'api-path' }, [host.path()]),
|
|
782
|
+
who || null,
|
|
783
|
+
h('button', {
|
|
784
|
+
type: 'button',
|
|
785
|
+
class: 'api-close',
|
|
786
|
+
'aria-label': 'Close the API drawer',
|
|
787
|
+
onclick: () => show(false),
|
|
788
|
+
}, ['×']),
|
|
789
|
+
]), h('div', { class: 'api-body' }, [
|
|
790
|
+
h('div', { class: 'api-list' }, [
|
|
791
|
+
sessionBox(),
|
|
792
|
+
...(rows.length ? rows : [h('div', { class: 'api-empty' }, ['None on this page.'])]),
|
|
793
|
+
]),
|
|
794
|
+
result(ui.history.find((r) => r.id === selected)),
|
|
795
|
+
]));
|
|
796
|
+
}
|
|
797
|
+
async function reload() {
|
|
798
|
+
if (!shown)
|
|
799
|
+
return;
|
|
800
|
+
[list, endpoints, session] = await Promise.all([
|
|
801
|
+
effects(host.path()),
|
|
802
|
+
getJson('/_hozu/dev/endpoints').then((e) => e ?? []),
|
|
803
|
+
getJson('/_hozu/dev/session'),
|
|
804
|
+
]);
|
|
805
|
+
inputs.clear();
|
|
806
|
+
if (ui.tab === 'endpoint' && !endpoints.length)
|
|
807
|
+
ui.tab = 'query';
|
|
808
|
+
draw();
|
|
809
|
+
}
|
|
810
|
+
function show(open) {
|
|
811
|
+
shown = open;
|
|
812
|
+
ui.open = open;
|
|
813
|
+
persist();
|
|
814
|
+
el.hidden = !open;
|
|
815
|
+
resize();
|
|
816
|
+
if (open)
|
|
817
|
+
void reload();
|
|
818
|
+
else
|
|
819
|
+
host.closed();
|
|
820
|
+
}
|
|
821
|
+
return {
|
|
822
|
+
el,
|
|
823
|
+
show,
|
|
824
|
+
reload,
|
|
825
|
+
toggle: () => show(!shown),
|
|
826
|
+
isOpen: () => shown,
|
|
827
|
+
height,
|
|
828
|
+
restore: () => ui.open && show(true),
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
//# sourceMappingURL=effects.js.map
|