@ternkit/tern 1.0.0 → 1.2.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/goscript/index.svelte.d.ts +44 -0
- package/dist/goscript/index.svelte.js +204 -0
- package/dist/image/Image.svelte +62 -0
- package/dist/image/Image.svelte.d.ts +26 -0
- package/dist/image/types.d.ts +15 -0
- package/dist/image/types.js +1 -0
- package/dist/index.d.ts +14 -1
- package/dist/index.js +5 -1
- package/dist/runtime/api.d.ts +18 -0
- package/dist/runtime/api.js +55 -0
- package/dist/runtime/base.d.ts +2 -0
- package/dist/runtime/base.js +10 -0
- package/dist/runtime/format.d.ts +19 -0
- package/dist/runtime/format.js +279 -0
- package/dist/runtime/i18n.svelte.d.ts +50 -0
- package/dist/runtime/i18n.svelte.js +105 -0
- package/dist/runtime/realtime.svelte.d.ts +78 -0
- package/dist/runtime/realtime.svelte.js +163 -0
- package/dist/runtime/router.svelte.js +16 -0
- package/dist/runtime/types.d.ts +6 -0
- package/dist/server/email.d.ts +18 -0
- package/dist/server/email.js +34 -0
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.js +6 -0
- package/dist/server/intl.js +1 -1
- package/dist/server/polyfills.d.ts +1 -2
- package/dist/server/polyfills.js +29 -2
- package/dist/server/url.d.ts +56 -0
- package/dist/server/url.js +594 -0
- package/dist/server/web.d.ts +74 -0
- package/dist/server/web.js +395 -0
- package/dist/vite/goscript.d.ts +12 -0
- package/dist/vite/goscript.js +226 -0
- package/dist/vite/image.d.ts +4 -0
- package/dist/vite/image.js +180 -0
- package/dist/vite/index.d.ts +6 -0
- package/dist/vite/index.js +161 -4
- package/package.json +14 -3
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Message formatting for i18n. This mirrors internal/i18n in Go exactly:
|
|
2
|
+
// both are tested against internal/i18n/testdata/cases.json, so the server
|
|
3
|
+
// and the browser produce the same text and hydration matches.
|
|
4
|
+
const cache = new Map();
|
|
5
|
+
/** Formats one message in the given locale. */
|
|
6
|
+
export function format(locale, message, params = {}) {
|
|
7
|
+
let nodes = cache.get(message);
|
|
8
|
+
if (nodes === undefined) {
|
|
9
|
+
try {
|
|
10
|
+
nodes = parse(message);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
nodes = null;
|
|
14
|
+
}
|
|
15
|
+
cache.set(message, nodes);
|
|
16
|
+
}
|
|
17
|
+
if (!nodes)
|
|
18
|
+
return message;
|
|
19
|
+
return render(nodes, locale, params, undefined);
|
|
20
|
+
}
|
|
21
|
+
function render(nodes, locale, params, hash) {
|
|
22
|
+
let out = '';
|
|
23
|
+
for (const n of nodes) {
|
|
24
|
+
switch (n.type) {
|
|
25
|
+
case 'text':
|
|
26
|
+
out += n.text;
|
|
27
|
+
break;
|
|
28
|
+
case 'hash':
|
|
29
|
+
out += hash === undefined ? '#' : formatNumber(hash);
|
|
30
|
+
break;
|
|
31
|
+
case 'arg':
|
|
32
|
+
out += n.name in params ? String(params[n.name]) : `{${n.name}}`;
|
|
33
|
+
break;
|
|
34
|
+
case 'plural': {
|
|
35
|
+
const num = Number(params[n.name] ?? 0) || 0;
|
|
36
|
+
const body = n.options[`=${formatNumber(num)}`] ?? n.options[pluralCategory(locale, num)] ?? n.options.other;
|
|
37
|
+
out += render(body, locale, params, num);
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
case 'select': {
|
|
41
|
+
const body = n.options[String(params[n.name])] ?? n.options.other;
|
|
42
|
+
out += render(body, locale, params, hash);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
function formatNumber(n) {
|
|
49
|
+
return String(n);
|
|
50
|
+
}
|
|
51
|
+
export function parse(message) {
|
|
52
|
+
const p = { s: [...message], i: 0 };
|
|
53
|
+
const nodes = parseNodes(p, false, false);
|
|
54
|
+
if (p.i < p.s.length)
|
|
55
|
+
throw new Error(`unexpected '}' at ${p.i}`);
|
|
56
|
+
return nodes;
|
|
57
|
+
}
|
|
58
|
+
function parseNodes(p, inBranch, plural) {
|
|
59
|
+
const out = [];
|
|
60
|
+
let text = '';
|
|
61
|
+
const flush = () => {
|
|
62
|
+
if (text)
|
|
63
|
+
out.push({ type: 'text', text });
|
|
64
|
+
text = '';
|
|
65
|
+
};
|
|
66
|
+
while (p.i < p.s.length) {
|
|
67
|
+
const c = p.s[p.i];
|
|
68
|
+
if (c === "'") {
|
|
69
|
+
const next = p.s[p.i + 1];
|
|
70
|
+
if (next === "'") {
|
|
71
|
+
text += "'";
|
|
72
|
+
p.i += 2;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (next !== undefined && '{}#'.includes(next)) {
|
|
76
|
+
let end = p.i + 1;
|
|
77
|
+
while (end < p.s.length && p.s[end] !== "'")
|
|
78
|
+
end++;
|
|
79
|
+
text += p.s.slice(p.i + 1, end).join('');
|
|
80
|
+
p.i = end + 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
text += c;
|
|
84
|
+
p.i++;
|
|
85
|
+
}
|
|
86
|
+
else if (c === '{') {
|
|
87
|
+
flush();
|
|
88
|
+
out.push(parseArgument(p));
|
|
89
|
+
}
|
|
90
|
+
else if (c === '}') {
|
|
91
|
+
if (!inBranch)
|
|
92
|
+
throw new Error(`unexpected '}' at ${p.i}`);
|
|
93
|
+
flush();
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
else if (c === '#' && plural) {
|
|
97
|
+
flush();
|
|
98
|
+
out.push({ type: 'hash' });
|
|
99
|
+
p.i++;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
text += c;
|
|
103
|
+
p.i++;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (inBranch)
|
|
107
|
+
throw new Error("unclosed '{'");
|
|
108
|
+
flush();
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
function until(p, stops) {
|
|
112
|
+
const start = p.i;
|
|
113
|
+
while (p.i < p.s.length && !stops.includes(p.s[p.i]))
|
|
114
|
+
p.i++;
|
|
115
|
+
return p.s.slice(start, p.i).join('');
|
|
116
|
+
}
|
|
117
|
+
function parseArgument(p) {
|
|
118
|
+
p.i++; // {
|
|
119
|
+
const name = until(p, ',}').trim();
|
|
120
|
+
if (!name)
|
|
121
|
+
throw new Error(`empty argument at ${p.i}`);
|
|
122
|
+
if (p.i >= p.s.length)
|
|
123
|
+
throw new Error("unclosed '{'");
|
|
124
|
+
if (p.s[p.i] === '}') {
|
|
125
|
+
p.i++;
|
|
126
|
+
return { type: 'arg', name };
|
|
127
|
+
}
|
|
128
|
+
p.i++; // ,
|
|
129
|
+
const kind = until(p, ',}').trim();
|
|
130
|
+
if (kind !== 'plural' && kind !== 'select')
|
|
131
|
+
throw new Error(`unknown argument type "${kind}"`);
|
|
132
|
+
if (p.s[p.i] !== ',')
|
|
133
|
+
throw new Error(`${kind} argument "${name}" needs options`);
|
|
134
|
+
p.i++;
|
|
135
|
+
const options = {};
|
|
136
|
+
for (;;) {
|
|
137
|
+
while (p.i < p.s.length && ' \n\t'.includes(p.s[p.i]))
|
|
138
|
+
p.i++;
|
|
139
|
+
if (p.i >= p.s.length)
|
|
140
|
+
throw new Error("unclosed '{'");
|
|
141
|
+
if (p.s[p.i] === '}') {
|
|
142
|
+
p.i++;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
const key = until(p, '{').trim();
|
|
146
|
+
if (!key || p.i >= p.s.length)
|
|
147
|
+
throw new Error(`expected an option in "${name}"`);
|
|
148
|
+
p.i++; // {
|
|
149
|
+
options[key] = parseNodes(p, true, kind === 'plural');
|
|
150
|
+
p.i++; // }
|
|
151
|
+
}
|
|
152
|
+
if (!options.other)
|
|
153
|
+
throw new Error(`${kind} argument "${name}" needs an 'other' option`);
|
|
154
|
+
return { type: kind, name, options };
|
|
155
|
+
}
|
|
156
|
+
/** The CLDR cardinal plural category of n in locale (see the Go version). */
|
|
157
|
+
export function pluralCategory(locale, n) {
|
|
158
|
+
const lang = locale.toLowerCase().split(/[-_]/)[0];
|
|
159
|
+
const i = Math.abs(Math.trunc(n));
|
|
160
|
+
const isInt = n === Math.trunc(n);
|
|
161
|
+
const mod10 = i % 10;
|
|
162
|
+
const mod100 = i % 100;
|
|
163
|
+
switch (lang) {
|
|
164
|
+
case 'ja':
|
|
165
|
+
case 'zh':
|
|
166
|
+
case 'ko':
|
|
167
|
+
case 'vi':
|
|
168
|
+
case 'th':
|
|
169
|
+
case 'id':
|
|
170
|
+
case 'ms':
|
|
171
|
+
case 'lo':
|
|
172
|
+
case 'my':
|
|
173
|
+
case 'km':
|
|
174
|
+
return 'other';
|
|
175
|
+
case 'fr':
|
|
176
|
+
case 'pt':
|
|
177
|
+
case 'hy':
|
|
178
|
+
case 'ff':
|
|
179
|
+
case 'kab':
|
|
180
|
+
if (i === 0 || i === 1)
|
|
181
|
+
return 'one';
|
|
182
|
+
break;
|
|
183
|
+
case 'ru':
|
|
184
|
+
case 'uk':
|
|
185
|
+
case 'be':
|
|
186
|
+
case 'sr':
|
|
187
|
+
case 'hr':
|
|
188
|
+
case 'bs':
|
|
189
|
+
if (!isInt)
|
|
190
|
+
return 'other';
|
|
191
|
+
if (mod10 === 1 && mod100 !== 11)
|
|
192
|
+
return 'one';
|
|
193
|
+
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
|
|
194
|
+
return 'few';
|
|
195
|
+
if (lang === 'ru' || lang === 'uk' || lang === 'be')
|
|
196
|
+
return 'many';
|
|
197
|
+
break;
|
|
198
|
+
case 'pl':
|
|
199
|
+
if (!isInt)
|
|
200
|
+
return 'other';
|
|
201
|
+
if (i === 1)
|
|
202
|
+
return 'one';
|
|
203
|
+
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
|
|
204
|
+
return 'few';
|
|
205
|
+
return 'many';
|
|
206
|
+
case 'cs':
|
|
207
|
+
case 'sk':
|
|
208
|
+
if (!isInt)
|
|
209
|
+
return 'many';
|
|
210
|
+
if (i === 1)
|
|
211
|
+
return 'one';
|
|
212
|
+
if (i >= 2 && i <= 4)
|
|
213
|
+
return 'few';
|
|
214
|
+
break;
|
|
215
|
+
case 'ro':
|
|
216
|
+
if (i === 1 && isInt)
|
|
217
|
+
return 'one';
|
|
218
|
+
if (!isInt || i === 0 || (mod100 >= 2 && mod100 <= 19))
|
|
219
|
+
return 'few';
|
|
220
|
+
break;
|
|
221
|
+
case 'lt':
|
|
222
|
+
if (mod10 === 1 && (mod100 < 11 || mod100 > 19))
|
|
223
|
+
return 'one';
|
|
224
|
+
if (mod10 >= 2 && (mod100 < 11 || mod100 > 19))
|
|
225
|
+
return 'few';
|
|
226
|
+
break;
|
|
227
|
+
case 'lv':
|
|
228
|
+
if (mod10 === 0 || (mod100 >= 11 && mod100 <= 19))
|
|
229
|
+
return 'zero';
|
|
230
|
+
if (mod10 === 1 && mod100 !== 11)
|
|
231
|
+
return 'one';
|
|
232
|
+
break;
|
|
233
|
+
case 'ar':
|
|
234
|
+
if (n === 0)
|
|
235
|
+
return 'zero';
|
|
236
|
+
if (n === 1)
|
|
237
|
+
return 'one';
|
|
238
|
+
if (n === 2)
|
|
239
|
+
return 'two';
|
|
240
|
+
if (mod100 >= 3 && mod100 <= 10)
|
|
241
|
+
return 'few';
|
|
242
|
+
if (mod100 >= 11 && mod100 <= 99)
|
|
243
|
+
return 'many';
|
|
244
|
+
break;
|
|
245
|
+
case 'he':
|
|
246
|
+
case 'iw':
|
|
247
|
+
if (i === 1 && isInt)
|
|
248
|
+
return 'one';
|
|
249
|
+
if (i === 2 && isInt)
|
|
250
|
+
return 'two';
|
|
251
|
+
break;
|
|
252
|
+
case 'ga':
|
|
253
|
+
if (n === 1)
|
|
254
|
+
return 'one';
|
|
255
|
+
if (n === 2)
|
|
256
|
+
return 'two';
|
|
257
|
+
if (n >= 3 && n <= 6)
|
|
258
|
+
return 'few';
|
|
259
|
+
if (n >= 7 && n <= 10)
|
|
260
|
+
return 'many';
|
|
261
|
+
break;
|
|
262
|
+
case 'cy':
|
|
263
|
+
if (n === 0)
|
|
264
|
+
return 'zero';
|
|
265
|
+
if (n === 1)
|
|
266
|
+
return 'one';
|
|
267
|
+
if (n === 2)
|
|
268
|
+
return 'two';
|
|
269
|
+
if (n === 3)
|
|
270
|
+
return 'few';
|
|
271
|
+
if (n === 6)
|
|
272
|
+
return 'many';
|
|
273
|
+
break;
|
|
274
|
+
default:
|
|
275
|
+
if (n === 1 && isInt)
|
|
276
|
+
return 'one';
|
|
277
|
+
}
|
|
278
|
+
return 'other';
|
|
279
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Messages } from '../index.js';
|
|
2
|
+
/** A message key from src/locales, or any string when the app has none. */
|
|
3
|
+
export type MessageKey = [keyof Messages] extends [never] ? string : keyof Messages & string;
|
|
4
|
+
type Nested = {
|
|
5
|
+
[key: string]: string | Nested;
|
|
6
|
+
};
|
|
7
|
+
export interface I18nConfig {
|
|
8
|
+
defaultLocale: string;
|
|
9
|
+
locales?: string[];
|
|
10
|
+
/** Client: lazy loaders. */
|
|
11
|
+
load?: Record<string, () => Promise<{
|
|
12
|
+
default: Nested;
|
|
13
|
+
}>>;
|
|
14
|
+
/** Server: every locale's messages, eagerly imported. */
|
|
15
|
+
messages?: Record<string, Nested>;
|
|
16
|
+
}
|
|
17
|
+
export declare function configureI18n(c: I18nConfig | null): void;
|
|
18
|
+
/** Loads a locale's messages (and the default's, for fallbacks) and makes it current. */
|
|
19
|
+
export declare function useLocale(locale: string | undefined): Promise<void>;
|
|
20
|
+
/** Synchronous version for the server renderer, where every locale is loaded. */
|
|
21
|
+
export declare function useLocaleSync(locale: string | undefined): void;
|
|
22
|
+
/**
|
|
23
|
+
* Translates a message from src/locales into the current locale:
|
|
24
|
+
*
|
|
25
|
+
* ```svelte
|
|
26
|
+
* <h1>{t('welcome', { name: data.user })}</h1>
|
|
27
|
+
* <p>{t('todos.left', { count: data.left })}</p>
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* Messages use ICU syntax for placeholders, plurals and selects. A message
|
|
31
|
+
* missing from the current locale falls back to the default locale, and
|
|
32
|
+
* then to the key.
|
|
33
|
+
*/
|
|
34
|
+
export declare function t(key: MessageKey, params?: Record<string, unknown>): string;
|
|
35
|
+
/** The current locale and the ones the app supports. */
|
|
36
|
+
export declare const i18n: {
|
|
37
|
+
/** The current locale, such as "de", or "" if the app has no src/locales. */
|
|
38
|
+
readonly locale: string;
|
|
39
|
+
/** Every locale in src/locales. */
|
|
40
|
+
readonly locales: string[];
|
|
41
|
+
/** The locale used for missing messages. */
|
|
42
|
+
readonly defaultLocale: string;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Switches the user to another locale: stores it in the locale cookie, which
|
|
46
|
+
* the server prefers over Accept-Language, and reloads the page's data. For
|
|
47
|
+
* locales in the URL (a lang route parameter), link to the other URL instead.
|
|
48
|
+
*/
|
|
49
|
+
export declare function setLocale(locale: string, base?: string): Promise<void>;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { format } from './format.js';
|
|
2
|
+
class I18nState {
|
|
3
|
+
locale = $state('');
|
|
4
|
+
messages = $state({});
|
|
5
|
+
fallback = {};
|
|
6
|
+
}
|
|
7
|
+
const state = new I18nState();
|
|
8
|
+
let config = null;
|
|
9
|
+
const loaded = new Map();
|
|
10
|
+
const warned = new Set();
|
|
11
|
+
function flatten(tree, prefix = '', out = {}) {
|
|
12
|
+
for (const [k, v] of Object.entries(tree)) {
|
|
13
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
14
|
+
if (typeof v === 'string')
|
|
15
|
+
out[key] = v;
|
|
16
|
+
else
|
|
17
|
+
flatten(v, key, out);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
export function configureI18n(c) {
|
|
22
|
+
config = c;
|
|
23
|
+
if (c?.messages) {
|
|
24
|
+
for (const [l, m] of Object.entries(c.messages))
|
|
25
|
+
loaded.set(l, flatten(m));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function messagesFor(locale) {
|
|
29
|
+
const hit = loaded.get(locale);
|
|
30
|
+
if (hit)
|
|
31
|
+
return hit;
|
|
32
|
+
const loader = config?.load?.[locale];
|
|
33
|
+
if (!loader)
|
|
34
|
+
return {};
|
|
35
|
+
const flat = flatten((await loader()).default);
|
|
36
|
+
loaded.set(locale, flat);
|
|
37
|
+
return flat;
|
|
38
|
+
}
|
|
39
|
+
/** Loads a locale's messages (and the default's, for fallbacks) and makes it current. */
|
|
40
|
+
export async function useLocale(locale) {
|
|
41
|
+
if (!config || !locale)
|
|
42
|
+
return;
|
|
43
|
+
const [messages, fallback] = await Promise.all([messagesFor(locale), messagesFor(config.defaultLocale)]);
|
|
44
|
+
state.fallback = fallback;
|
|
45
|
+
state.messages = messages;
|
|
46
|
+
state.locale = locale;
|
|
47
|
+
if (typeof document !== 'undefined')
|
|
48
|
+
document.documentElement.lang = locale;
|
|
49
|
+
}
|
|
50
|
+
/** Synchronous version for the server renderer, where every locale is loaded. */
|
|
51
|
+
export function useLocaleSync(locale) {
|
|
52
|
+
if (!config || !locale)
|
|
53
|
+
return;
|
|
54
|
+
state.fallback = loaded.get(config.defaultLocale) ?? {};
|
|
55
|
+
state.messages = loaded.get(locale) ?? {};
|
|
56
|
+
state.locale = locale;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Translates a message from src/locales into the current locale:
|
|
60
|
+
*
|
|
61
|
+
* ```svelte
|
|
62
|
+
* <h1>{t('welcome', { name: data.user })}</h1>
|
|
63
|
+
* <p>{t('todos.left', { count: data.left })}</p>
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* Messages use ICU syntax for placeholders, plurals and selects. A message
|
|
67
|
+
* missing from the current locale falls back to the default locale, and
|
|
68
|
+
* then to the key.
|
|
69
|
+
*/
|
|
70
|
+
export function t(key, params) {
|
|
71
|
+
const message = state.messages[key] ?? state.fallback[key];
|
|
72
|
+
if (message === undefined) {
|
|
73
|
+
if (import.meta.env?.DEV && !warned.has(key)) {
|
|
74
|
+
warned.add(key);
|
|
75
|
+
console.warn(`[tern] missing message "${key}"`);
|
|
76
|
+
}
|
|
77
|
+
return key;
|
|
78
|
+
}
|
|
79
|
+
return format(state.locale || config?.defaultLocale || 'en', message, params);
|
|
80
|
+
}
|
|
81
|
+
/** The current locale and the ones the app supports. */
|
|
82
|
+
export const i18n = {
|
|
83
|
+
/** The current locale, such as "de", or "" if the app has no src/locales. */
|
|
84
|
+
get locale() {
|
|
85
|
+
return state.locale;
|
|
86
|
+
},
|
|
87
|
+
/** Every locale in src/locales. */
|
|
88
|
+
get locales() {
|
|
89
|
+
return config?.locales ?? Object.keys(config?.messages ?? {});
|
|
90
|
+
},
|
|
91
|
+
/** The locale used for missing messages. */
|
|
92
|
+
get defaultLocale() {
|
|
93
|
+
return config?.defaultLocale ?? '';
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Switches the user to another locale: stores it in the locale cookie, which
|
|
98
|
+
* the server prefers over Accept-Language, and reloads the page's data. For
|
|
99
|
+
* locales in the URL (a lang route parameter), link to the other URL instead.
|
|
100
|
+
*/
|
|
101
|
+
export async function setLocale(locale, base = '') {
|
|
102
|
+
document.cookie = `locale=${encodeURIComponent(locale)}; path=${base || '/'}; max-age=31536000; samesite=lax`;
|
|
103
|
+
const { invalidateAll } = await import('./router.svelte.js');
|
|
104
|
+
await invalidateAll();
|
|
105
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export type ConnectionStatus = 'connecting' | 'open' | 'closed';
|
|
2
|
+
export interface StreamOptions<T> {
|
|
3
|
+
/** Event names to listen for. The default is the unnamed "message" event. */
|
|
4
|
+
events?: string[];
|
|
5
|
+
/** The value of current before the first event, such as data from a load. */
|
|
6
|
+
initial?: T;
|
|
7
|
+
/** Called for each event, with its decoded data and name. */
|
|
8
|
+
onMessage?: (data: T, event: string) => void;
|
|
9
|
+
/** Send cookies to another origin. */
|
|
10
|
+
withCredentials?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** A server-sent event stream; see stream(). */
|
|
13
|
+
export declare class EventStreamState<T> {
|
|
14
|
+
#private;
|
|
15
|
+
/** The data of the latest event, decoded from JSON when it is JSON. */
|
|
16
|
+
current: T | undefined;
|
|
17
|
+
/** The connection's status. The browser reconnects by itself after errors. */
|
|
18
|
+
status: ConnectionStatus;
|
|
19
|
+
constructor(url: string, options?: StreamOptions<T>);
|
|
20
|
+
/** Closes the stream. Streams created in a component close with it. */
|
|
21
|
+
close(): void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Subscribes to server-sent events from an endpoint that uses
|
|
25
|
+
* ctx.EventStream or realtime.Stream:
|
|
26
|
+
*
|
|
27
|
+
* ```svelte
|
|
28
|
+
* <script lang="ts">
|
|
29
|
+
* import { stream } from '@ternkit/tern';
|
|
30
|
+
* let { data } = $props();
|
|
31
|
+
* const monitors = stream<Monitor[]>('/api/monitors/events', { events: ['monitors'], initial: data.monitors });
|
|
32
|
+
* </script>
|
|
33
|
+
*
|
|
34
|
+
* {#each monitors.current ?? [] as m}…{/each}
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* During server rendering it does not connect and current is the initial
|
|
38
|
+
* value. Created in a component, it closes when the component is destroyed.
|
|
39
|
+
*/
|
|
40
|
+
export declare function stream<T = unknown>(url: string, options?: StreamOptions<T>): EventStreamState<T>;
|
|
41
|
+
export interface SocketOptions<In> {
|
|
42
|
+
/** Called for each message, decoded from JSON when it is JSON. */
|
|
43
|
+
onMessage?: (message: In) => void;
|
|
44
|
+
/** WebSocket subprotocols. */
|
|
45
|
+
protocols?: string[];
|
|
46
|
+
/** Reconnect with backoff after the connection drops. The default is true. */
|
|
47
|
+
reconnect?: boolean;
|
|
48
|
+
}
|
|
49
|
+
/** A WebSocket connection; see socket(). */
|
|
50
|
+
export declare class SocketState<In = unknown, Out = unknown> {
|
|
51
|
+
#private;
|
|
52
|
+
/** The latest message received. */
|
|
53
|
+
last: In | undefined;
|
|
54
|
+
/** The connection's status. */
|
|
55
|
+
status: ConnectionStatus;
|
|
56
|
+
constructor(url: string, options?: SocketOptions<In>);
|
|
57
|
+
/** Sends a message as JSON (strings as is). Messages sent while connecting are queued. */
|
|
58
|
+
send(message: Out): void;
|
|
59
|
+
/** Closes the connection. Sockets created in a component close with it. */
|
|
60
|
+
close(): void;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Opens a WebSocket to an endpoint that uses ctx.WebSocket, with JSON
|
|
64
|
+
* messages and automatic reconnection:
|
|
65
|
+
*
|
|
66
|
+
* ```svelte
|
|
67
|
+
* <script lang="ts">
|
|
68
|
+
* import { socket } from '@ternkit/tern';
|
|
69
|
+
* const chat = socket<ChatMessage, { text: string }>('/api/chat', { onMessage: (m) => messages.push(m) });
|
|
70
|
+
* </script>
|
|
71
|
+
*
|
|
72
|
+
* <button onclick={() => chat.send({ text })}>Send</button>
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* It does not connect during server rendering. Created in a component, it
|
|
76
|
+
* closes when the component is destroyed.
|
|
77
|
+
*/
|
|
78
|
+
export declare function socket<In = unknown, Out = unknown>(url: string, options?: SocketOptions<In>): SocketState<In, Out>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { onDestroy } from 'svelte';
|
|
2
|
+
/** Closes c when the component that created it is destroyed, if any. */
|
|
3
|
+
function closeWithComponent(c) {
|
|
4
|
+
try {
|
|
5
|
+
onDestroy(() => c.close());
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
// Created outside a component: the caller closes it.
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const browser = typeof window !== 'undefined';
|
|
12
|
+
function parse(data) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(data);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return data;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** A server-sent event stream; see stream(). */
|
|
21
|
+
export class EventStreamState {
|
|
22
|
+
/** The data of the latest event, decoded from JSON when it is JSON. */
|
|
23
|
+
current = $state(undefined);
|
|
24
|
+
/** The connection's status. The browser reconnects by itself after errors. */
|
|
25
|
+
status = $state('connecting');
|
|
26
|
+
#source = null;
|
|
27
|
+
constructor(url, options = {}) {
|
|
28
|
+
this.current = options.initial;
|
|
29
|
+
if (!browser) {
|
|
30
|
+
this.status = 'closed';
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const source = new EventSource(url, { withCredentials: options.withCredentials });
|
|
34
|
+
this.#source = source;
|
|
35
|
+
const handle = (e) => {
|
|
36
|
+
const data = parse(e.data);
|
|
37
|
+
this.current = data;
|
|
38
|
+
options.onMessage?.(data, e.type);
|
|
39
|
+
};
|
|
40
|
+
for (const name of options.events?.length ? options.events : ['message'])
|
|
41
|
+
source.addEventListener(name, handle);
|
|
42
|
+
source.onopen = () => (this.status = 'open');
|
|
43
|
+
source.onerror = () => (this.status = source.readyState === EventSource.CLOSED ? 'closed' : 'connecting');
|
|
44
|
+
closeWithComponent(this);
|
|
45
|
+
}
|
|
46
|
+
/** Closes the stream. Streams created in a component close with it. */
|
|
47
|
+
close() {
|
|
48
|
+
this.#source?.close();
|
|
49
|
+
this.#source = null;
|
|
50
|
+
this.status = 'closed';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Subscribes to server-sent events from an endpoint that uses
|
|
55
|
+
* ctx.EventStream or realtime.Stream:
|
|
56
|
+
*
|
|
57
|
+
* ```svelte
|
|
58
|
+
* <script lang="ts">
|
|
59
|
+
* import { stream } from '@ternkit/tern';
|
|
60
|
+
* let { data } = $props();
|
|
61
|
+
* const monitors = stream<Monitor[]>('/api/monitors/events', { events: ['monitors'], initial: data.monitors });
|
|
62
|
+
* </script>
|
|
63
|
+
*
|
|
64
|
+
* {#each monitors.current ?? [] as m}…{/each}
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* During server rendering it does not connect and current is the initial
|
|
68
|
+
* value. Created in a component, it closes when the component is destroyed.
|
|
69
|
+
*/
|
|
70
|
+
export function stream(url, options) {
|
|
71
|
+
return new EventStreamState(url, options);
|
|
72
|
+
}
|
|
73
|
+
/** A WebSocket connection; see socket(). */
|
|
74
|
+
export class SocketState {
|
|
75
|
+
/** The latest message received. */
|
|
76
|
+
last = $state(undefined);
|
|
77
|
+
/** The connection's status. */
|
|
78
|
+
status = $state('connecting');
|
|
79
|
+
#ws = null;
|
|
80
|
+
#queue = [];
|
|
81
|
+
#closed = false;
|
|
82
|
+
#attempt = 0;
|
|
83
|
+
#timer;
|
|
84
|
+
#url;
|
|
85
|
+
#options;
|
|
86
|
+
constructor(url, options = {}) {
|
|
87
|
+
this.#url = url;
|
|
88
|
+
this.#options = options;
|
|
89
|
+
if (!browser) {
|
|
90
|
+
this.status = 'closed';
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
this.#connect();
|
|
94
|
+
closeWithComponent(this);
|
|
95
|
+
}
|
|
96
|
+
#connect() {
|
|
97
|
+
const u = new URL(this.#url, location.href);
|
|
98
|
+
if (u.protocol === 'http:')
|
|
99
|
+
u.protocol = 'ws:';
|
|
100
|
+
else if (u.protocol === 'https:')
|
|
101
|
+
u.protocol = 'wss:';
|
|
102
|
+
const ws = new WebSocket(u, this.#options.protocols);
|
|
103
|
+
this.#ws = ws;
|
|
104
|
+
this.status = 'connecting';
|
|
105
|
+
ws.onopen = () => {
|
|
106
|
+
this.#attempt = 0;
|
|
107
|
+
this.status = 'open';
|
|
108
|
+
for (const m of this.#queue.splice(0))
|
|
109
|
+
ws.send(m);
|
|
110
|
+
};
|
|
111
|
+
ws.onmessage = (e) => {
|
|
112
|
+
const msg = (typeof e.data === 'string' ? parse(e.data) : e.data);
|
|
113
|
+
this.last = msg;
|
|
114
|
+
this.#options.onMessage?.(msg);
|
|
115
|
+
};
|
|
116
|
+
ws.onclose = (e) => {
|
|
117
|
+
this.#ws = null;
|
|
118
|
+
// 1000 is a normal close by the server: don't come back.
|
|
119
|
+
if (this.#closed || this.#options.reconnect === false || e.code === 1000) {
|
|
120
|
+
this.status = 'closed';
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.status = 'connecting';
|
|
124
|
+
const delay = Math.min(10_000, 500 * 2 ** this.#attempt++) * (0.75 + Math.random() / 2);
|
|
125
|
+
this.#timer = setTimeout(() => this.#connect(), delay);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Sends a message as JSON (strings as is). Messages sent while connecting are queued. */
|
|
129
|
+
send(message) {
|
|
130
|
+
const data = typeof message === 'string' ? message : JSON.stringify(message);
|
|
131
|
+
if (this.#ws?.readyState === WebSocket.OPEN)
|
|
132
|
+
this.#ws.send(data);
|
|
133
|
+
else if (!this.#closed)
|
|
134
|
+
this.#queue.push(data);
|
|
135
|
+
}
|
|
136
|
+
/** Closes the connection. Sockets created in a component close with it. */
|
|
137
|
+
close() {
|
|
138
|
+
this.#closed = true;
|
|
139
|
+
clearTimeout(this.#timer);
|
|
140
|
+
this.#ws?.close(1000);
|
|
141
|
+
this.#ws = null;
|
|
142
|
+
this.status = 'closed';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Opens a WebSocket to an endpoint that uses ctx.WebSocket, with JSON
|
|
147
|
+
* messages and automatic reconnection:
|
|
148
|
+
*
|
|
149
|
+
* ```svelte
|
|
150
|
+
* <script lang="ts">
|
|
151
|
+
* import { socket } from '@ternkit/tern';
|
|
152
|
+
* const chat = socket<ChatMessage, { text: string }>('/api/chat', { onMessage: (m) => messages.push(m) });
|
|
153
|
+
* </script>
|
|
154
|
+
*
|
|
155
|
+
* <button onclick={() => chat.send({ text })}>Send</button>
|
|
156
|
+
* ```
|
|
157
|
+
*
|
|
158
|
+
* It does not connect during server rendering. Created in a component, it
|
|
159
|
+
* closes when the component is destroyed.
|
|
160
|
+
*/
|
|
161
|
+
export function socket(url, options) {
|
|
162
|
+
return new SocketState(url, options);
|
|
163
|
+
}
|