@hozu/devtools 0.12.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.
@@ -1,15 +1,9 @@
1
- var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
- if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
- return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
- return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
- });
6
- }
7
- return path;
8
- };
9
- import { h } from './dom.js';
10
- export const effects = (path) => fetch(`/_hozu/dev/effects?path=${encodeURIComponent(path)}`)
1
+ import { h, read, write } from './dom.js';
2
+ import { curl, traced } from './trace.js';
3
+ const getJson = (path) => fetch(path)
11
4
  .then((r) => (r.ok ? r.json() : null))
12
5
  .catch(() => null);
6
+ export const effects = (path) => getJson(`/_hozu/dev/effects?path=${encodeURIComponent(path)}`);
13
7
  const payloadOf = (doc) => {
14
8
  try {
15
9
  return JSON.parse(doc.getElementById('hozu-payload')?.textContent ?? 'null');
@@ -18,32 +12,23 @@ const payloadOf = (doc) => {
18
12
  return null;
19
13
  }
20
14
  };
21
- /** The input the page is using for a query, from its payload; else an example from the schema. */
22
- function inputFor(effect, doc) {
23
- const data = (payloadOf(doc)?.data ?? []);
24
- const key = data
25
- .map(([k]) => k)
26
- .find((k) => k.startsWith(effect.ref) && '{['.includes(k[effect.ref.length] ?? ''));
27
- if (key) {
28
- try {
29
- return JSON.parse(key.slice(effect.ref.length));
30
- }
31
- catch { }
32
- }
33
- return example(effect.input);
34
- }
35
- function example(schema) {
15
+ export function example(schema) {
16
+ if (!schema)
17
+ return null;
36
18
  if (schema.default !== undefined)
37
19
  return schema.default;
38
20
  if (Array.isArray(schema.enum))
39
21
  return schema.enum[0];
40
22
  if ('const' in schema)
41
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]);
42
27
  const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
43
28
  if (type === 'object')
44
29
  return Object.fromEntries(Object.entries((schema.properties ?? {})).map(([k, v]) => [k, example(v)]));
45
30
  if (type === 'array')
46
- return [];
31
+ return schema.items ? [example(schema.items)] : [];
47
32
  if (type === 'number' || type === 'integer')
48
33
  return 0;
49
34
  if (type === 'boolean')
@@ -52,105 +37,296 @@ function example(schema) {
52
37
  return null;
53
38
  return '';
54
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
+ }
55
53
  const scalar = (s) => ['string', 'number', 'integer', 'boolean'].includes(s.type) || Array.isArray(s.enum);
56
- /** Server effects go through the app's own endpoints; browser-run ones load the page's fetch bundle. */
57
- export async function call(effect, input, doc) {
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
58
  const started = performance.now();
59
- const elapsed = () => Math.round(performance.now() - started);
60
- if (effect.runs === 'browser') {
61
- const payload = payloadOf(doc);
62
- const feature = effect.ref.slice(0, effect.ref.indexOf('.'));
63
- const url = payload?.fetches?.[feature];
64
- if (!url)
65
- return {
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({
66
89
  ok: false,
67
90
  error: 'Unexpected',
68
- value: 'This page has no fetch bundle',
69
- ms: 0,
70
- invalidated: [],
71
- where: 'browser',
72
- };
73
- const failed = Symbol('fail');
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') {
74
130
  try {
75
- const module = (await import(__rewriteRelativeImportExtension(/* @vite-ignore */ url)));
76
- const value = await module[effect.ref.slice(feature.length + 1)](input, {
77
- fail: (error, data) => {
78
- throw { [failed]: { error, data } };
79
- },
80
- signal: new AbortController().signal,
81
- env: payload?.env ?? {},
82
- });
83
- return { ok: true, error: null, value, ms: elapsed(), invalidated: [], where: 'browser' };
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));
84
135
  }
85
- catch (thrown) {
86
- const marked = thrown?.[failed];
87
- return marked
88
- ? {
89
- ok: false,
90
- error: marked.error,
91
- value: marked.data,
92
- ms: elapsed(),
93
- invalidated: [],
94
- where: 'browser',
95
- }
96
- : {
97
- ok: false,
98
- error: 'Unexpected',
99
- value: String(thrown),
100
- ms: elapsed(),
101
- invalidated: [],
102
- where: 'browser',
103
- };
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);
104
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
+ };
105
180
  }
106
- const query = effect.kind === 'query';
107
- const response = await fetch(query ? '/_hozu/query' : '/_hozu/effect', {
108
- method: 'POST',
109
- headers: { 'content-type': 'application/json' },
110
- body: JSON.stringify(query ? { query: effect.ref, input } : { effect: effect.ref, input, keys: [] }),
111
- });
112
- if (!response.ok)
181
+ catch (error) {
113
182
  return {
183
+ ...base,
114
184
  ok: false,
115
185
  error: 'Unexpected',
116
- value: await response.text(),
117
- ms: elapsed(),
118
- invalidated: [],
119
- where: 'server',
186
+ value: String(error),
187
+ ms: Math.round(performance.now() - started),
188
+ status: null,
189
+ headers: [],
120
190
  };
121
- const body = (await response.json());
122
- const result = (query ? body : body.result);
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;
123
200
  return {
124
- ok: result.ok === true,
125
- error: result.ok ? null : String(result.error),
126
- value: result.ok ? result.value : result.data,
127
- ms: elapsed(),
128
- invalidated: query ? [] : (body.tags ?? []),
201
+ kind: 'query',
202
+ runs: 'server',
203
+ input: null,
204
+ ok: false,
205
+ error: null,
206
+ value: null,
207
+ ms: 0,
129
208
  where: 'server',
209
+ tags: [],
210
+ refreshed: false,
211
+ status: null,
212
+ headers: [],
213
+ requests: [],
214
+ at: o.id,
215
+ ...o,
130
216
  };
131
- }
132
- const where = (runs, plain) => runs === 'server'
133
- ? plain
134
- ? 'on your server'
135
- : 'runs: server'
136
- : runs === 'browser'
137
- ? plain
138
- ? 'in the browser'
139
- : 'runs: browser'
140
- : plain
141
- ? 'server first, then the browser'
142
- : 'runs: either';
143
- function fields(effect, start, onChange) {
144
- const schema = effect.input;
145
- const props = (schema.properties ?? {});
146
- if (schema.type === 'object' && Object.values(props).every(scalar)) {
147
- const value = { ...start };
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
+ ]);
148
323
  if (!Object.keys(props).length)
149
- return h('div', { class: 'plain' }, ['No input.']);
150
- return h('div', { class: 'fields' }, Object.entries(props).map(([name, s]) => {
151
- const id = `hz-api-${effect.ref}-${name}`;
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}`;
152
328
  const control = Array.isArray(s.enum)
153
- ? h('select', { id }, s.enum.map((o) => h('option', { value: String(o), selected: value[name] === o }, [String(o)])))
329
+ ? h('select', { id }, s.enum.map((o) => h('option', { value: String(o), selected: current[name] === o }, [String(o)])))
154
330
  : h('input', {
155
331
  id,
156
332
  type: s.type === 'boolean'
@@ -158,103 +334,498 @@ function fields(effect, start, onChange) {
158
334
  : s.type === 'number' || s.type === 'integer'
159
335
  ? 'number'
160
336
  : 'text',
337
+ placeholder: s.type === 'string' ? name : '',
161
338
  ...(s.type === 'boolean'
162
- ? { checked: value[name] === true }
163
- : { value: String(value[name] ?? '') }),
339
+ ? { checked: current[name] === true }
340
+ : { value: String(current[name] ?? '') }),
341
+ 'aria-invalid': invalid[name] ? 'true' : undefined,
164
342
  });
165
- control.addEventListener('input', () => {
166
- const el = control;
167
- value[name] =
343
+ const update = () => {
344
+ const c = control;
345
+ current[name] =
168
346
  s.type === 'boolean'
169
- ? el.checked
347
+ ? c.checked
170
348
  : s.type === 'number' || s.type === 'integer'
171
- ? Number(el.value)
172
- : el.value;
173
- onChange({ ...value });
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
+ }
174
361
  });
175
- return h('label', { class: 'field', for: id }, [h('span', {}, [name]), control]);
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
+ ]);
176
367
  }));
177
- }
178
- const area = h('textarea', { class: 'json', rows: '4', 'aria-label': 'Input (JSON)' }, [
179
- JSON.stringify(start, null, 2),
180
- ]);
181
- area.addEventListener('input', () => {
182
- try {
183
- onChange(JSON.parse(area.value));
184
- area.removeAttribute('aria-invalid');
185
- }
186
- catch {
187
- area.setAttribute('aria-invalid', 'true');
368
+ };
369
+ const ask = (effect) => {
370
+ if (effect.kind === 'mutation' && confirming !== effect.ref) {
371
+ confirming = effect.ref;
372
+ return draw();
188
373
  }
189
- });
190
- return area;
191
- }
192
- export function renderEffects(panel, list, host) {
193
- const outcome = (r) => h('div', { class: `result ${r.ok ? 'ok' : 'bad'}`, role: 'status' }, [
194
- h('div', { class: 'label' }, [
195
- r.ok ? 'OK' : r.error === 'Unexpected' ? 'Unexpected error' : `Declared error: ${r.error}`,
196
- ` · ${r.ms} ms · ${r.where === 'server' ? 'through the server' : 'in this browser (not checked against the schema)'}`,
197
- ]),
198
- h('pre', { class: 'value' }, [JSON.stringify(r.value, null, 2) ?? 'undefined']),
199
- r.invalidated.length
200
- ? h('div', { class: 'plain' }, [
201
- `Invalidated ${r.invalidated.join(', ')}. `,
202
- h('button', { class: 'link', type: 'button', onclick: host.reload }, [
203
- 'Reload the page to see it',
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']),
204
500
  ]),
205
- ])
206
- : null,
207
- ]);
208
- const card = (effect) => {
209
- let input = inputFor(effect, host.doc);
210
- const out = h('div');
211
- const writes = effect.kind === 'mutation';
212
- const run = h('button', {
213
- class: writes ? 'warn' : '',
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', {
214
526
  type: 'button',
527
+ class: 'api-ghost',
528
+ disabled,
529
+ title,
215
530
  onclick: async () => {
216
- if (writes && !confirm(`${effect.label} writes your development data. Run it?`))
217
- return;
218
- run.setAttribute('disabled', '');
219
- out.replaceChildren(h('div', { class: 'plain' }, ['Running…']));
220
- out.replaceChildren(outcome(await call(effect, input, host.doc)));
221
- run.removeAttribute('disabled');
531
+ await navigator.clipboard.writeText(text()).catch(() => { });
532
+ button.textContent = 'Copied';
222
533
  },
223
- }, [writes ? 'Run · writes your development data' : 'Run']);
224
- return h('details', { class: 'effect', 'data-effect': effect.ref }, [
225
- h('summary', {}, [
226
- h('b', {}, [host.plain ? effect.label : effect.ref]),
227
- h('span', { class: 'badge-ref' }, [where(effect.runs, host.plain)]),
228
- host.plain ? null : h('span', { class: 'badge-ref' }, [effect.scope, ' · ', effect.freshness]),
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}`,
229
582
  ]),
230
- host.plain
231
- ? null
232
- : h('div', { class: 'meta' }, [
233
- effect.tags.length ? `tags ${effect.tags.join(', ')} · ` : '',
234
- effect.invalidates.length ? `invalidates ${effect.invalidates.join(', ')} · ` : '',
235
- `errors ${effect.errors.join(', ')} · used by ${effect.usedBy.join(', ')}`,
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'])),
236
603
  ]),
237
- fields(effect, input, (v) => {
238
- input = v;
239
- }),
240
- h('div', { class: 'actions' }, [run]),
241
- out,
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),
242
622
  ]);
243
- };
244
- const queries = (list ?? []).filter((e) => e.kind === 'query');
245
- const mutations = (list ?? []).filter((e) => e.kind === 'mutation');
246
- panel.hidden = false;
247
- panel.replaceChildren(h('div', { class: 'head' }, [
248
- h('div', { class: 'kicker' }, [location.pathname]),
249
- h('h2', { class: 'title' }, ['API']),
250
- h('button', { class: 'close', type: 'button', 'aria-label': 'Close', onclick: host.close }, ['×']),
251
- ]), list === null
252
- ? h('div', { class: 'empty' }, ['This page is not a Hozu page.'])
253
- : h('div', { class: 'sec' }, [
254
- h('div', { class: 'label' }, [host.plain ? 'Data this page reads' : 'Queries']),
255
- ...(queries.length ? queries.map(card) : [h('div', { class: 'plain' }, ['None.'])]),
256
- h('div', { class: 'label' }, [host.plain ? 'Changes this page can make' : 'Mutations']),
257
- ...(mutations.length ? mutations.map(card) : [h('div', { class: 'plain' }, ['None.'])]),
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)),
258
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
+ };
259
830
  }
260
831
  //# sourceMappingURL=effects.js.map