@junoflow/cli 0.6.0 → 0.9.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/app-client.js ADDED
@@ -0,0 +1,306 @@
1
+ /*
2
+ * The Juno app client, served at /<id>/_juno/app.js.
3
+ *
4
+ * The apps listener prepends one line before this file:
5
+ *
6
+ * window.__JUNO_APP__={"id":"echo","version":3,"base":"/echo/"};
7
+ *
8
+ * naming the version that served the page. Everything here is plain ES2020
9
+ * with no imports and no build step: it is embedded into the Go binary as is
10
+ * (sdkembed.go) and tested by sdk/test/app-client.test.js.
11
+ *
12
+ * window.juno.handlers.<name>(input) → Promise<output>
13
+ * POSTs input as JSON to the handler and resolves with its result, or
14
+ * rejects with an Error whose message is the handler's (or the host's).
15
+ *
16
+ * window.juno.values
17
+ * The app's bound states and controls, by binding name:
18
+ * {states: {<b>: value}, controls: {<b>: {value, active, expires_at}}}, or
19
+ * null until the first snapshot has arrived.
20
+ *
21
+ * window.juno.watch(cb) → unwatch()
22
+ * Calls cb(values, changed) on every snapshot and update, where changed
23
+ * lists the keys that moved ("states.<b>", "controls.<b>"; every key on a
24
+ * snapshot). Registered after a snapshot, cb is called at once with every
25
+ * key.
26
+ *
27
+ * window.juno.controls.<binding>.onPress(cb) → unwatch()
28
+ * Calls cb(at) on every press of that bound button control, at an RFC 3339
29
+ * timestamp.
30
+ *
31
+ * handlers and controls are proxies, not maps of what the manifest declares:
32
+ * the client is never told the names, so any name reaches the server, and an
33
+ * unknown handler is a rejected call carrying the host's 404 — the same answer
34
+ * a hand-typed name got before. A name some JavaScript protocol looks up on
35
+ * every object (then, toJSON, toString, …) answers undefined instead, so
36
+ * `await juno.handlers` or JSON.stringify(juno) never fires a call;
37
+ * manifest.ValidateApp refuses those names for a handler or a control binding.
38
+ *
39
+ * The values arrive over one EventSource on _juno/stream, opened by the first
40
+ * watch or onPress and never on page load: a page that does not watch holds no
41
+ * connection. Every connect starts with a fresh snapshot, which replaces the
42
+ * values outright, so a dropped connection costs nothing; when the page becomes
43
+ * visible with its source closed (mobile browsers drop background
44
+ * connections), the client opens a new one.
45
+ *
46
+ * Version skew: every response names the version that answered it, and the
47
+ * stream sends `stale` when the page's version is no longer what its path
48
+ * serves. Either way the client dispatches a cancelable `juno:stale` event on
49
+ * window, once per page load; a stale stream is closed and never reopened.
50
+ * Unless a listener calls preventDefault(), the client shows a small
51
+ * "Updated — tap to reload" bar. Tapping it reloads, except on a ~draft/ page
52
+ * whose own version became the active one: that draft is gone, so the bar goes
53
+ * to the app's base instead.
54
+ */
55
+ (function () {
56
+ 'use strict';
57
+
58
+ var app = window.__JUNO_APP__ || {};
59
+ var base = app.base || '/';
60
+ var version = app.version;
61
+ var draftSuffix = '~draft/';
62
+ var staleSeen = false;
63
+
64
+ function checkVersion(res) {
65
+ var answered = res.headers && res.headers.get('X-Juno-App-Version');
66
+ if (!answered || Number(answered) === version) return;
67
+ reportStale(Number(answered));
68
+ }
69
+
70
+ // reportStale dispatches juno:stale, once per page load, whether the call
71
+ // path or the stream noticed first.
72
+ function reportStale(current) {
73
+ if (staleSeen) return;
74
+ staleSeen = true;
75
+ var event = new CustomEvent('juno:stale', {
76
+ cancelable: true,
77
+ detail: { version: version, current: current },
78
+ });
79
+ if (!window.dispatchEvent(event)) return;
80
+ // A ~draft/ page told that its own version is now current was activated:
81
+ // ~draft/ no longer serves it, and reloading would show a 404.
82
+ var activated =
83
+ current === version && base.slice(-draftSuffix.length) === draftSuffix;
84
+ showReloadBar(
85
+ activated ? base.slice(0, base.length - draftSuffix.length) : null
86
+ );
87
+ }
88
+
89
+ function showReloadBar(href) {
90
+ var bar = document.createElement('div');
91
+ bar.setAttribute('role', 'button');
92
+ bar.textContent = 'Updated — tap to reload';
93
+ bar.style.cssText =
94
+ 'position:fixed;left:0;right:0;bottom:0;z-index:2147483647;padding:12px 16px;' +
95
+ 'background:#1f2937;color:#fff;font:14px/1.4 system-ui,sans-serif;' +
96
+ 'text-align:center;cursor:pointer';
97
+ bar.addEventListener('click', function () {
98
+ if (href) window.location.assign(href);
99
+ else window.location.reload();
100
+ });
101
+ document.body.appendChild(bar);
102
+ }
103
+
104
+ // namespace answers every name with make(name), made once, except the
105
+ // protocol names in the header comment. Keep the list in step with
106
+ // manifest.appReservedNames.
107
+ function namespace(make) {
108
+ var made = Object.create(null);
109
+ return new Proxy(Object.create(null), {
110
+ get: function (target, name) {
111
+ if (typeof name !== 'string' || reserved(name)) return undefined;
112
+ return made[name] || (made[name] = make(name));
113
+ },
114
+ set: function () {
115
+ return false;
116
+ },
117
+ });
118
+ }
119
+
120
+ function reserved(name) {
121
+ return name === 'then' || name === 'toJSON' || name in Object.prototype;
122
+ }
123
+
124
+ async function call(name, input) {
125
+ var res = await fetch(base + '_juno/call/' + encodeURIComponent(name), {
126
+ method: 'POST',
127
+ headers: {
128
+ 'Content-Type': 'application/json',
129
+ 'X-Juno-App-Version': String(version),
130
+ },
131
+ body: JSON.stringify(input === undefined ? {} : input),
132
+ });
133
+ checkVersion(res);
134
+ var body;
135
+ try {
136
+ body = await res.json();
137
+ } catch (e) {
138
+ throw new Error('HTTP ' + res.status);
139
+ }
140
+ if (!res.ok) {
141
+ throw new Error(
142
+ body && typeof body.error === 'string'
143
+ ? body.error
144
+ : 'HTTP ' + res.status
145
+ );
146
+ }
147
+ return body.result;
148
+ }
149
+
150
+ // Live values. The stream is opened lazily and at most one is open.
151
+ var values = null;
152
+ var watchers = [];
153
+ var pressHandlers = Object.create(null);
154
+ var source = null;
155
+ var streamStale = false;
156
+ var visibilityHooked = false;
157
+
158
+ function openStream() {
159
+ if (streamStale || (source && source.readyState !== EventSource.CLOSED)) {
160
+ return;
161
+ }
162
+ source = new EventSource(base + '_juno/stream?v=' + version);
163
+ source.addEventListener('snapshot', onSnapshot);
164
+ source.addEventListener('update', onUpdate);
165
+ source.addEventListener('pressed', onPressed);
166
+ source.addEventListener('stale', onStale);
167
+ if (!visibilityHooked) {
168
+ visibilityHooked = true;
169
+ document.addEventListener('visibilitychange', function () {
170
+ // A source the browser gave up on stays closed; one it is still
171
+ // retrying is left to its own reconnect.
172
+ if (document.visibilityState === 'visible') openStream();
173
+ });
174
+ }
175
+ }
176
+
177
+ function parse(ev) {
178
+ try {
179
+ return JSON.parse(ev.data);
180
+ } catch (e) {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ function keys(vals) {
186
+ var out = [];
187
+ Object.keys(vals.states || {}).forEach(function (b) {
188
+ out.push('states.' + b);
189
+ });
190
+ Object.keys(vals.controls || {}).forEach(function (b) {
191
+ out.push('controls.' + b);
192
+ });
193
+ return out;
194
+ }
195
+
196
+ function notify(cb, changed) {
197
+ try {
198
+ cb(values, changed);
199
+ } catch (e) {
200
+ console.error(e);
201
+ }
202
+ }
203
+
204
+ function onSnapshot(ev) {
205
+ var data = parse(ev);
206
+ if (!data || !data.values) return;
207
+ values = {
208
+ states: data.values.states || {},
209
+ controls: data.values.controls || {},
210
+ };
211
+ var changed = keys(values);
212
+ watchers.slice().forEach(function (cb) {
213
+ notify(cb, changed);
214
+ });
215
+ }
216
+
217
+ // onUpdate merges per binding. An update carries both maps, the one that did
218
+ // not move empty, so the changed keys are exactly the bindings present.
219
+ function onUpdate(ev) {
220
+ var data = parse(ev);
221
+ if (!data || !data.values || !values) return;
222
+ var moved = {
223
+ states: data.values.states || {},
224
+ controls: data.values.controls || {},
225
+ };
226
+ values = {
227
+ states: Object.assign({}, values.states, moved.states),
228
+ controls: Object.assign({}, values.controls, moved.controls),
229
+ };
230
+ var changed = keys(moved);
231
+ if (!changed.length) return;
232
+ watchers.slice().forEach(function (cb) {
233
+ notify(cb, changed);
234
+ });
235
+ }
236
+
237
+ function onPressed(ev) {
238
+ var data = parse(ev);
239
+ if (!data) return;
240
+ (pressHandlers[data.binding] || []).slice().forEach(function (cb) {
241
+ try {
242
+ cb(data.at);
243
+ } catch (e) {
244
+ console.error(e);
245
+ }
246
+ });
247
+ }
248
+
249
+ // onStale closes the source before anything else: the server ends the
250
+ // response, and an EventSource left open would reconnect into another stale.
251
+ function onStale(ev) {
252
+ streamStale = true;
253
+ source.close();
254
+ var data = parse(ev);
255
+ reportStale(data && typeof data.version === 'number' ? data.version : NaN);
256
+ }
257
+
258
+ function remover(list, cb) {
259
+ return function () {
260
+ var i = list.indexOf(cb);
261
+ if (i >= 0) list.splice(i, 1);
262
+ };
263
+ }
264
+
265
+ function watch(cb) {
266
+ if (typeof cb !== 'function') {
267
+ throw new TypeError('juno.watch: callback must be a function');
268
+ }
269
+ watchers.push(cb);
270
+ openStream();
271
+ if (values) notify(cb, keys(values));
272
+ return remover(watchers, cb);
273
+ }
274
+
275
+ function onPress(binding, cb) {
276
+ if (typeof cb !== 'function') {
277
+ throw new TypeError(
278
+ 'juno.controls.' + binding + '.onPress: callback must be a function'
279
+ );
280
+ }
281
+ var list = pressHandlers[binding] || (pressHandlers[binding] = []);
282
+ list.push(cb);
283
+ openStream();
284
+ return remover(list, cb);
285
+ }
286
+
287
+ window.juno = Object.freeze({
288
+ app: Object.freeze({ id: app.id, version: version, base: base }),
289
+ handlers: namespace(function (name) {
290
+ return function (input) {
291
+ return call(name, input);
292
+ };
293
+ }),
294
+ controls: namespace(function (binding) {
295
+ return Object.freeze({
296
+ onPress: function (cb) {
297
+ return onPress(binding, cb);
298
+ },
299
+ });
300
+ }),
301
+ get values() {
302
+ return values;
303
+ },
304
+ watch: watch,
305
+ });
306
+ })();
@@ -0,0 +1,34 @@
1
+ import tsParser from '@typescript-eslint/parser';
2
+ import juno from '@junoflow/eslint-plugin';
3
+
4
+ /**
5
+ * The lint preset for an app working copy (`juno app init`).
6
+ *
7
+ * The rules come from the plugin's `rulesets.json`, `configs.app` — the set
8
+ * Juno's verify step lints an app's src/ with — so the author runs the rules
9
+ * the server runs, by construction rather than by two lists.
10
+ *
11
+ * Only the handlers are linted. page/ is not: the `app` ruleset is written
12
+ * for handlers (a page's clock and module state are not a handler's), and
13
+ * verify does not lint page/ either, so a local lint matches the server's.
14
+ * The generated `.d.ts` files are not linted: verify lints the author's
15
+ * source and nothing else, and app-env.d.ts carries an `eslint-disable`
16
+ * comment naming a rule this config does not load, which eslint would report
17
+ * as unknown. Neither is test/: the app rules are written for handler code,
18
+ * and a test calling `Date.now()` is not a defect.
19
+ */
20
+ export default [
21
+ {
22
+ ignores: ['node_modules/**', 'dist/**', '**/*.d.ts', 'page/**', 'test/**'],
23
+ },
24
+ {
25
+ files: ['**/*.ts'],
26
+ languageOptions: {
27
+ parser: tsParser,
28
+ ecmaVersion: 2022,
29
+ sourceType: 'module',
30
+ },
31
+ plugins: { juno },
32
+ rules: { ...juno.configs.app },
33
+ },
34
+ ];
@@ -5,7 +5,7 @@ import juno from '@junoflow/eslint-plugin';
5
5
  * The lint preset for a flow working copy (`juno flow init`).
6
6
  *
7
7
  * The rules come from the plugin's `rulesets.json`, `configs.flow` — the set
8
- * Juno's verify step lints a flow's `flow.ts` with — so the author runs the
8
+ * Juno's verify step lints a flow's `src/` with — so the author runs the
9
9
  * rules the server runs, by construction rather than by two lists.
10
10
  *
11
11
  * One rule reports nothing here: `juno/prefer-claim-over-set` needs to know
@@ -15,10 +15,12 @@ import juno from '@junoflow/eslint-plugin';
15
15
  *
16
16
  * The generated `.d.ts` files are not linted: verify lints the author's source
17
17
  * and nothing else, and the flow rules are about handlers they do not contain.
18
+ * Neither is test/: the flow rules are written for handler code, and a test
19
+ * calling `Date.now()` is not a defect.
18
20
  */
19
21
  export default [
20
22
  {
21
- ignores: ['node_modules/**', 'dist/**', '**/*.d.ts'],
23
+ ignores: ['node_modules/**', 'dist/**', '**/*.d.ts', 'test/**'],
22
24
  },
23
25
  {
24
26
  files: ['**/*.ts'],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@junoflow/cli",
3
- "version": "0.6.0",
4
- "description": "Develop Juno integrations and operate a Juno instance from the command line",
3
+ "version": "0.9.0",
4
+ "description": "Develop Juno flows, integrations and apps, and operate a Juno instance from the command line",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -16,16 +16,21 @@
16
16
  ".": "./src/index.js",
17
17
  "./package.json": "./package.json",
18
18
  "./tsconfig.base.json": "./tsconfig.base.json",
19
+ "./tsconfig.page.json": "./tsconfig.page.json",
19
20
  "./eslint.config.js": "./eslint.config.js",
20
- "./eslint.flow.config.js": "./eslint.flow.config.js"
21
+ "./eslint.flow.config.js": "./eslint.flow.config.js",
22
+ "./eslint.app.config.js": "./eslint.app.config.js"
21
23
  },
22
24
  "files": [
23
25
  "bin",
24
26
  "src",
25
27
  "tsconfig.base.json",
28
+ "tsconfig.page.json",
26
29
  "eslint.config.js",
27
30
  "eslint.flow.config.js",
31
+ "eslint.app.config.js",
28
32
  "verbs.json",
33
+ "app-client.js",
29
34
  "README.md"
30
35
  ],
31
36
  "scripts": {
@@ -35,7 +40,7 @@
35
40
  "node": ">=20"
36
41
  },
37
42
  "dependencies": {
38
- "@junoflow/eslint-plugin": "0.6.0",
43
+ "@junoflow/eslint-plugin": "0.9.0",
39
44
  "@typescript-eslint/parser": "^8.0.0"
40
45
  },
41
46
  "devDependencies": {