@ternkit/tern 1.1.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.
@@ -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
+ }
@@ -3,6 +3,7 @@ import DefaultError from './DefaultError.svelte';
3
3
  import Passthrough from './Passthrough.svelte';
4
4
  import Root from './Root.svelte';
5
5
  import { DeferredScope, readStreamed, revive } from './deferred.js';
6
+ import { setBase } from './base.js';
6
7
  import { configureI18n, useLocale } from './i18n.svelte.js';
7
8
  import { runAfter, runBefore, runOn } from './navigation.js';
8
9
  import { plan } from './select.js';
@@ -32,6 +33,7 @@ const RELOAD = Symbol('reload');
32
33
  /** Starts the client. Called by the generated entry module. */
33
34
  export async function start(m) {
34
35
  manifest = m;
36
+ setBase(m.base ?? '');
35
37
  configureI18n(m.i18n ?? null);
36
38
  const el = document.getElementById('__tern_data');
37
39
  const target = document.getElementById('tern-root');
@@ -0,0 +1,18 @@
1
+ import type { Component } from 'svelte';
2
+ /** An email component module from src/emails. */
3
+ export interface EmailModule {
4
+ default: Component<any>;
5
+ /** Props for the preview page under tern dev. */
6
+ preview?: Record<string, unknown>;
7
+ }
8
+ export interface EmailResult {
9
+ subject: string;
10
+ html: string;
11
+ error?: string;
12
+ }
13
+ /**
14
+ * Creates the function Go calls to render emails: a component's markup
15
+ * becomes the body, its <svelte:head> the head, and its <title> the
16
+ * subject.
17
+ */
18
+ export declare function createEmailRenderer(emails: Record<string, EmailModule>): (name: string, propsJSON: string) => string;
@@ -0,0 +1,34 @@
1
+ import { render } from 'svelte/server';
2
+ const entities = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&#x27;': "'" };
3
+ function decode(s) {
4
+ return s.replace(/&(amp|lt|gt|quot|#39|#x27);/g, (m) => entities[m] ?? m);
5
+ }
6
+ /**
7
+ * Creates the function Go calls to render emails: a component's markup
8
+ * becomes the body, its <svelte:head> the head, and its <title> the
9
+ * subject.
10
+ */
11
+ export function createEmailRenderer(emails) {
12
+ return (name, propsJSON) => {
13
+ if (name === '') {
14
+ // The list of emails and their preview props, for the dev preview.
15
+ return JSON.stringify(Object.fromEntries(Object.entries(emails).map(([n, m]) => [n, m.preview ?? {}])));
16
+ }
17
+ const mod = emails[name];
18
+ if (!mod)
19
+ return JSON.stringify({ subject: '', html: '', error: `no email named ${name} in src/emails` });
20
+ const props = JSON.parse(propsJSON || '{}');
21
+ const out = render(mod.default, { props });
22
+ const title = /<title>([\s\S]*?)<\/title>/.exec(out.head);
23
+ const subject = title ? decode(title[1].trim()) : '';
24
+ const head = out.head.replace(/<!--[\s\S]*?-->/g, '');
25
+ const body = out.body.replace(/<!--[\s\S]*?-->/g, '');
26
+ const html = '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">' +
27
+ '<meta name="color-scheme" content="light dark">' +
28
+ head +
29
+ '</head><body>' +
30
+ body +
31
+ '</body></html>';
32
+ return JSON.stringify({ subject, html });
33
+ };
34
+ }
@@ -21,6 +21,7 @@ export interface ServerManifest {
21
21
  }[];
22
22
  };
23
23
  i18n?: I18nConfig | null;
24
+ base?: string;
24
25
  }
25
26
  /** The result of a server render, as JSON for the Go side. */
26
27
  export interface RenderResult {
@@ -35,3 +36,5 @@ export interface RenderResult {
35
36
  * exactly the same tree and hydration matches.
36
37
  */
37
38
  export declare function createRenderer(manifest: ServerManifest): (payload: string, href: string) => string;
39
+ export { createEmailRenderer } from './email.js';
40
+ export type { EmailModule, EmailResult } from './email.js';
@@ -2,6 +2,7 @@ import { render } from 'svelte/server';
2
2
  import DefaultError from '../runtime/DefaultError.svelte';
3
3
  import Passthrough from '../runtime/Passthrough.svelte';
4
4
  import Root from '../runtime/Root.svelte';
5
+ import { setBase } from '../runtime/base.js';
5
6
  import { revive } from '../runtime/deferred.js';
6
7
  import { configureI18n, useLocaleSync } from '../runtime/i18n.svelte.js';
7
8
  import { plan } from '../runtime/select.js';
@@ -14,6 +15,7 @@ import { applyPayloadToPage } from '../runtime/state.svelte.js';
14
15
  export function createRenderer(manifest) {
15
16
  const layout = (i) => manifest.layouts[i]?.component ?? Passthrough;
16
17
  configureI18n(manifest.i18n ?? null);
18
+ setBase(manifest.base ?? '');
17
19
  return (payloadJSON, href) => {
18
20
  const p = JSON.parse(payloadJSON);
19
21
  // Deferred values are still loading: render their pending state.
@@ -41,3 +43,4 @@ export function createRenderer(manifest) {
41
43
  return JSON.stringify({ head: out.head, body: out.body });
42
44
  };
43
45
  }
46
+ export { createEmailRenderer } from './email.js';
@@ -33,7 +33,7 @@ class NumberFormat {
33
33
  }
34
34
  }
35
35
  const isCurrency = o.style === 'currency';
36
- const max = o.maximumFractionDigits ?? (isCurrency ? 2 : o.notation === 'compact' ? 1 : 3);
36
+ const max = o.maximumFractionDigits ?? (isCurrency ? 2 : o.style === 'percent' ? 0 : o.notation === 'compact' ? 1 : 3);
37
37
  const min = o.minimumFractionDigits ?? (isCurrency ? 2 : 0);
38
38
  let s = Math.abs(n).toFixed(Math.max(min, Math.min(max, 20)));
39
39
  if (s.includes('.')) {
@@ -1,2 +1 @@
1
- import 'core-js/actual/url';
2
- import 'core-js/actual/url-search-params';
1
+ export {};
@@ -2,17 +2,44 @@
2
2
  // JavaScript engine (QuickJS) that only implements the language itself.
3
3
  // Each polyfill is skipped where the API already exists, such as in Node
4
4
  // during development.
5
- import 'core-js/actual/url';
6
- import 'core-js/actual/url-search-params';
7
5
  import { IntlShim } from './intl.js';
6
+ import { URL, URLSearchParams } from './url.js';
7
+ import { install } from './web.js';
8
8
  const g = globalThis;
9
+ install(g);
9
10
  // Some libraries read process.env.SOMETHING at runtime. Give them an empty
10
11
  // environment instead of a ReferenceError.
11
12
  if (typeof g.process === 'undefined') {
12
13
  g.process = { env: { NODE_ENV: 'production' }, browser: false, version: '', versions: {} };
13
14
  }
15
+ if (typeof g.URL === 'undefined') {
16
+ g.URL = URL;
17
+ g.URLSearchParams = URLSearchParams;
18
+ }
14
19
  if (typeof g.Intl === 'undefined') {
15
20
  g.Intl = IntlShim;
21
+ // QuickJS's own toLocaleString ignores locales: (5e6).toLocaleString()
22
+ // is "5000000" where browsers print "5,000,000". Route them through the
23
+ // shim so server-rendered text matches the browser's en-US output.
24
+ const NF = IntlShim.NumberFormat;
25
+ const DF = IntlShim.DateTimeFormat;
26
+ Object.defineProperty(Number.prototype, 'toLocaleString', {
27
+ value(locales, options) {
28
+ return new NF(locales, options).format(Number(this));
29
+ },
30
+ configurable: true,
31
+ writable: true
32
+ });
33
+ const dateMethod = (defaults) => function (locales, options) {
34
+ return new DF(locales, { ...defaults, ...options }).format(this);
35
+ };
36
+ for (const [name, defaults] of [
37
+ ['toLocaleString', { dateStyle: 'short', timeStyle: 'medium' }],
38
+ ['toLocaleDateString', { dateStyle: 'short' }],
39
+ ['toLocaleTimeString', { timeStyle: 'medium' }]
40
+ ]) {
41
+ Object.defineProperty(Date.prototype, name, { value: dateMethod(defaults), configurable: true, writable: true });
42
+ }
16
43
  }
17
44
  if (typeof g.queueMicrotask !== 'function') {
18
45
  g.queueMicrotask = (fn) => void Promise.resolve().then(fn);
@@ -0,0 +1,56 @@
1
+ export declare class URLSearchParams {
2
+ #private;
3
+ constructor(init?: string | Record<string, string> | Iterable<[string, string]> | URLSearchParams);
4
+ /** @internal Links the params to their URL, so changes update it. */
5
+ static _link(params: URLSearchParams, url: URL, query: string | null): void;
6
+ /** @internal */
7
+ static _reset(params: URLSearchParams, query: string | null): void;
8
+ get size(): number;
9
+ append(name: string, value: string): void;
10
+ delete(name: string, value?: string): void;
11
+ get(name: string): string | null;
12
+ getAll(name: string): string[];
13
+ has(name: string, value?: string): boolean;
14
+ set(name: string, value: string): void;
15
+ sort(): void;
16
+ forEach(fn: (value: string, name: string, params: URLSearchParams) => void, thisArg?: unknown): void;
17
+ entries(): IterableIterator<[string, string]>;
18
+ keys(): IterableIterator<string>;
19
+ values(): IterableIterator<string>;
20
+ [Symbol.iterator](): IterableIterator<[string, string]>;
21
+ toString(): string;
22
+ get [Symbol.toStringTag](): string;
23
+ }
24
+ export declare class URL {
25
+ #private;
26
+ constructor(url: string | URL, base?: string | URL);
27
+ static canParse(url: string, base?: string): boolean;
28
+ static parse(url: string, base?: string): URL | null;
29
+ /** @internal */
30
+ static _setQuery(url: URL, query: string): void;
31
+ get href(): string;
32
+ set href(v: string);
33
+ get origin(): string;
34
+ get protocol(): string;
35
+ set protocol(v: string);
36
+ get username(): string;
37
+ set username(v: string);
38
+ get password(): string;
39
+ set password(v: string);
40
+ get host(): string;
41
+ set host(v: string);
42
+ get hostname(): string;
43
+ set hostname(v: string);
44
+ get port(): string;
45
+ set port(v: string);
46
+ get pathname(): string;
47
+ set pathname(v: string);
48
+ get search(): string;
49
+ set search(v: string);
50
+ get searchParams(): URLSearchParams;
51
+ get hash(): string;
52
+ set hash(v: string);
53
+ toString(): string;
54
+ toJSON(): string;
55
+ get [Symbol.toStringTag](): string;
56
+ }