@ternkit/tern 0.0.0-stage → 0.3.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Tern Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,50 @@
1
- # Temporary Holding Version
1
+ # @ternkit/tern
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The client runtime and Vite plugin for [Tern](https://github.com/Unfield/Tern), a full-stack framework with Svelte 5 on the client and Go on the server.
4
+
5
+ You normally get this package from a new project rather than installing it by hand:
6
+
7
+ ```sh
8
+ go run github.com/Unfield/Tern/cmd/tern@latest new my-app
9
+ ```
10
+
11
+ ## What it provides
12
+
13
+ **`@ternkit/tern`**: the API you use in components.
14
+
15
+ ```svelte
16
+ <script lang="ts">
17
+ import { page, navigating, goto, enhance } from '@ternkit/tern';
18
+ </script>
19
+ ```
20
+
21
+ | Export | Purpose |
22
+ |---|---|
23
+ | `page` | reactive state for the current page: `url`, `params`, `route`, `status`, `error`, `data`, `form` |
24
+ | `navigating` | the navigation in progress, or `null` |
25
+ | `goto`, `invalidateAll`, `preloadData` | client-side navigation and data |
26
+ | `enhance`, `applyAction` | progressive enhancement for forms that post to Go actions |
27
+
28
+ **`@ternkit/tern/vite`**: the Vite plugin. It includes the Svelte plugin, so it is the only plugin a Tern app needs.
29
+
30
+ ```js
31
+ // vite.config.js
32
+ import { tern } from '@ternkit/tern/vite';
33
+ import { defineConfig } from 'vite';
34
+
35
+ export default defineConfig({ plugins: [tern()] });
36
+ ```
37
+
38
+ ## Requirements
39
+
40
+ - Svelte 5.46 or newer
41
+ - Vite 8
42
+ - The matching version of the Tern Go module and CLI
43
+
44
+ ## Documentation
45
+
46
+ See the [Tern guide](https://github.com/Unfield/Tern/blob/main/docs/guide.md).
47
+
48
+ ## License
49
+
50
+ MIT
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Client API for Tern apps.
3
+ *
4
+ * ```svelte
5
+ * <script>
6
+ * import { page, goto, enhance } from '@ternkit/tern';
7
+ * </script>
8
+ * ```
9
+ */
10
+ export { page, navigating } from './runtime/state.svelte.js';
11
+ export { goto, invalidateAll, preloadData, applyAction } from './runtime/router.svelte.js';
12
+ export { enhance } from './runtime/forms.js';
13
+ export type { SubmitFunction, SubmitInput, SubmitResult } from './runtime/forms.js';
14
+ export type { ActionResult, GotoOptions, Navigation, NavigationTarget, NavigationType } from './runtime/types.js';
15
+ /** The version of the Tern client runtime. */
16
+ export declare const VERSION = "0.3.1";
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Client API for Tern apps.
3
+ *
4
+ * ```svelte
5
+ * <script>
6
+ * import { page, goto, enhance } from '@ternkit/tern';
7
+ * </script>
8
+ * ```
9
+ */
10
+ export { page, navigating } from './runtime/state.svelte.js';
11
+ export { goto, invalidateAll, preloadData, applyAction } from './runtime/router.svelte.js';
12
+ export { enhance } from './runtime/forms.js';
13
+ /** The version of the Tern client runtime. */
14
+ export const VERSION = '0.3.1';
@@ -0,0 +1,8 @@
1
+ <script lang="ts">
2
+ import { page } from './state.svelte.js';
3
+ </script>
4
+
5
+ <main style="font-family: system-ui, sans-serif; max-width: 36rem; margin: 4rem auto; padding: 0 1rem;">
6
+ <h1>{page.status}</h1>
7
+ <p>{page.error?.message}</p>
8
+ </main>
@@ -0,0 +1,18 @@
1
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
+ $$bindings?: Bindings;
4
+ } & Exports;
5
+ (internal: unknown, props: {
6
+ $$events?: Events;
7
+ $$slots?: Slots;
8
+ }): Exports & {
9
+ $set?: any;
10
+ $on?: any;
11
+ };
12
+ z_$$bindings?: Bindings;
13
+ }
14
+ declare const DefaultError: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
+ [evt: string]: CustomEvent<any>;
16
+ }, {}, {}, string>;
17
+ type DefaultError = InstanceType<typeof DefaultError>;
18
+ export default DefaultError;
@@ -0,0 +1,8 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ // Used for layout directories that have a LayoutLoad but no +layout.svelte.
5
+ let { children }: { children?: Snippet; data?: unknown } = $props();
6
+ </script>
7
+
8
+ {@render children?.()}
@@ -0,0 +1,8 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ children?: Snippet;
4
+ data?: unknown;
5
+ };
6
+ declare const Passthrough: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type Passthrough = ReturnType<typeof Passthrough>;
8
+ export default Passthrough;
@@ -0,0 +1,28 @@
1
+ <script lang="ts">
2
+ import type { Component } from 'svelte';
3
+
4
+ interface Props {
5
+ /** Layout components, outermost first, then the page or error component. */
6
+ components: Component<any>[];
7
+ /** Data for each component, already merged with its parents' data. */
8
+ data: Record<string, unknown>[];
9
+ form: unknown;
10
+ }
11
+
12
+ let { components, data, form }: Props = $props();
13
+ </script>
14
+
15
+ {#snippet level(i: number)}
16
+ {@const Current = components[i]}
17
+ {#if Current}
18
+ {#if i === components.length - 1}
19
+ <Current data={data[i]} {form} />
20
+ {:else}
21
+ <Current data={data[i]}>
22
+ {@render level(i + 1)}
23
+ </Current>
24
+ {/if}
25
+ {/if}
26
+ {/snippet}
27
+
28
+ {@render level(0)}
@@ -0,0 +1,11 @@
1
+ import type { Component } from 'svelte';
2
+ interface Props {
3
+ /** Layout components, outermost first, then the page or error component. */
4
+ components: Component<any>[];
5
+ /** Data for each component, already merged with its parents' data. */
6
+ data: Record<string, unknown>[];
7
+ form: unknown;
8
+ }
9
+ declare const Root: Component<Props, {}, "">;
10
+ type Root = ReturnType<typeof Root>;
11
+ export default Root;
@@ -0,0 +1 @@
1
+ export { start } from './router.svelte.js';
@@ -0,0 +1,2 @@
1
+ // Entry point used by the generated .tern/generated/client.js.
2
+ export { start } from './router.svelte.js';
@@ -0,0 +1,29 @@
1
+ /** A resolved or failed deferred value, as streamed by the server. */
2
+ export interface Chunk {
3
+ id: number;
4
+ data?: unknown;
5
+ error?: {
6
+ message: string;
7
+ };
8
+ }
9
+ /**
10
+ * Connects the deferred placeholders of one response to the chunks that
11
+ * resolve them. IDs are only unique within a response.
12
+ */
13
+ export declare class DeferredScope {
14
+ #private;
15
+ promise(id: number): Promise<unknown>;
16
+ settle(chunk: Chunk): void;
17
+ /** Rejects every value still pending, e.g. when the stream ends early. */
18
+ close(reason: string): void;
19
+ }
20
+ /** Replaces deferred placeholders in data with promises from scope. */
21
+ export declare function revive<T>(value: T, promise: (id: number) => Promise<unknown>): T;
22
+ /**
23
+ * Reads a response that holds one JSON payload line, optionally followed
24
+ * by one line per resolved deferred value. Resolves once the payload is
25
+ * read; deferred values keep streaming in the background.
26
+ */
27
+ export declare function readStreamed<P extends {
28
+ nodes?: unknown;
29
+ }>(res: Response): Promise<P>;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Connects the deferred placeholders of one response to the chunks that
3
+ * resolve them. IDs are only unique within a response.
4
+ */
5
+ export class DeferredScope {
6
+ #pending = new Map();
7
+ #early = new Map();
8
+ promise(id) {
9
+ const early = this.#early.get(id);
10
+ if (early) {
11
+ this.#early.delete(id);
12
+ return early.error ? Promise.reject(new Error(early.error.message)) : Promise.resolve(early.data);
13
+ }
14
+ return new Promise((resolve, reject) => this.#pending.set(id, { resolve, reject }));
15
+ }
16
+ settle(chunk) {
17
+ const p = this.#pending.get(chunk.id);
18
+ if (!p) {
19
+ this.#early.set(chunk.id, chunk);
20
+ return;
21
+ }
22
+ this.#pending.delete(chunk.id);
23
+ if (chunk.error)
24
+ p.reject(new Error(chunk.error.message));
25
+ else
26
+ p.resolve(chunk.data);
27
+ }
28
+ /** Rejects every value still pending, e.g. when the stream ends early. */
29
+ close(reason) {
30
+ for (const p of this.#pending.values())
31
+ p.reject(new Error(reason));
32
+ this.#pending.clear();
33
+ }
34
+ }
35
+ /** Replaces deferred placeholders in data with promises from scope. */
36
+ export function revive(value, promise) {
37
+ if (Array.isArray(value)) {
38
+ return value.map((v) => revive(v, promise));
39
+ }
40
+ if (value && typeof value === 'object') {
41
+ const obj = value;
42
+ const keys = Object.keys(obj);
43
+ if (keys.length === 1 && keys[0] === '__tern_deferred' && typeof obj.__tern_deferred === 'number') {
44
+ return promise(obj.__tern_deferred);
45
+ }
46
+ const out = {};
47
+ for (const k of keys)
48
+ out[k] = revive(obj[k], promise);
49
+ return out;
50
+ }
51
+ return value;
52
+ }
53
+ /**
54
+ * Reads a response that holds one JSON payload line, optionally followed
55
+ * by one line per resolved deferred value. Resolves once the payload is
56
+ * read; deferred values keep streaming in the background.
57
+ */
58
+ export async function readStreamed(res) {
59
+ if (!res.body) {
60
+ const [first, ...rest] = (await res.text()).split('\n');
61
+ const scope = new DeferredScope();
62
+ const payload = JSON.parse(first);
63
+ payload.nodes = revive(payload.nodes, (id) => scope.promise(id));
64
+ for (const line of rest)
65
+ if (line.trim())
66
+ scope.settle(JSON.parse(line));
67
+ scope.close('The server did not send this value');
68
+ return payload;
69
+ }
70
+ const reader = res.body.getReader();
71
+ const decoder = new TextDecoder();
72
+ let buffer = '';
73
+ let done = false;
74
+ while (!buffer.includes('\n') && !done) {
75
+ const r = await reader.read();
76
+ done = r.done;
77
+ if (r.value)
78
+ buffer += decoder.decode(r.value, { stream: true });
79
+ }
80
+ const newline = buffer.indexOf('\n');
81
+ const first = newline < 0 ? buffer : buffer.slice(0, newline);
82
+ buffer = newline < 0 ? '' : buffer.slice(newline + 1);
83
+ const scope = new DeferredScope();
84
+ const payload = JSON.parse(first);
85
+ payload.nodes = revive(payload.nodes, (id) => scope.promise(id));
86
+ void (async () => {
87
+ try {
88
+ for (;;) {
89
+ let i;
90
+ while ((i = buffer.indexOf('\n')) >= 0) {
91
+ const line = buffer.slice(0, i);
92
+ buffer = buffer.slice(i + 1);
93
+ if (line.trim())
94
+ scope.settle(JSON.parse(line));
95
+ }
96
+ if (done)
97
+ break;
98
+ const r = await reader.read();
99
+ done = r.done;
100
+ if (r.value)
101
+ buffer += decoder.decode(r.value, { stream: true });
102
+ if (done && buffer.trim())
103
+ buffer += '\n';
104
+ }
105
+ }
106
+ finally {
107
+ scope.close('The connection closed before this value arrived');
108
+ }
109
+ })();
110
+ return payload;
111
+ }
@@ -0,0 +1,39 @@
1
+ import type { ActionReturn } from 'svelte/action';
2
+ import type { ActionResult } from './types.js';
3
+ export interface SubmitInput {
4
+ action: URL;
5
+ formData: FormData;
6
+ formElement: HTMLFormElement;
7
+ controller: AbortController;
8
+ submitter: HTMLElement | null;
9
+ /** Stops the submission. */
10
+ cancel(): void;
11
+ }
12
+ export interface SubmitResult<Success, Failure> {
13
+ action: URL;
14
+ formData: FormData;
15
+ formElement: HTMLFormElement;
16
+ result: ActionResult<Success, Failure>;
17
+ /**
18
+ * Does what `use:enhance` does by default: reset the form and rerun
19
+ * loads after a success, then apply the result to the page.
20
+ */
21
+ update(opts?: {
22
+ reset?: boolean;
23
+ invalidateAll?: boolean;
24
+ }): Promise<void>;
25
+ }
26
+ /**
27
+ * Runs before a form is submitted. Return a function to handle the
28
+ * result yourself; call `update()` in it to keep the default behavior.
29
+ */
30
+ export type SubmitFunction<Success = Record<string, unknown>, Failure = Record<string, unknown>> = (input: SubmitInput) => void | ((result: SubmitResult<Success, Failure>) => void | Promise<void>) | Promise<void | ((result: SubmitResult<Success, Failure>) => void | Promise<void>)>;
31
+ /**
32
+ * Progressively enhances a form that posts to a page action. Without
33
+ * JavaScript the form still works; with it, the page updates in place.
34
+ *
35
+ * ```svelte
36
+ * <form method="POST" action="?/create" use:enhance>
37
+ * ```
38
+ */
39
+ export declare function enhance<Success = Record<string, unknown>, Failure = Record<string, unknown>>(form: HTMLFormElement, submit?: SubmitFunction<Success, Failure>): ActionReturn<SubmitFunction<Success, Failure> | undefined>;
@@ -0,0 +1,91 @@
1
+ import { applyAction, invalidateAll } from './router.svelte.js';
2
+ // Cloning a form without children reads its own action/method/enctype,
3
+ // even if it contains inputs named "action" or "method".
4
+ function attrs(form) {
5
+ return HTMLFormElement.prototype.cloneNode.call(form);
6
+ }
7
+ /**
8
+ * Progressively enhances a form that posts to a page action. Without
9
+ * JavaScript the form still works; with it, the page updates in place.
10
+ *
11
+ * ```svelte
12
+ * <form method="POST" action="?/create" use:enhance>
13
+ * ```
14
+ */
15
+ export function enhance(form, submit = () => { }) {
16
+ async function defaultUpdate(action, formElement, result, { reset = true, invalidateAll: invalidate = true } = {}) {
17
+ if (result.type === 'success') {
18
+ if (reset)
19
+ HTMLFormElement.prototype.reset.call(formElement);
20
+ if (invalidate)
21
+ await invalidateAll();
22
+ }
23
+ const samePage = location.origin + location.pathname === action.origin + action.pathname;
24
+ if (samePage || result.type === 'redirect' || result.type === 'error') {
25
+ await applyAction(result);
26
+ }
27
+ }
28
+ async function onSubmit(event) {
29
+ const submitter = event.submitter;
30
+ const own = attrs(form);
31
+ const method = submitter?.hasAttribute('formmethod') ? submitter.formMethod : own.method;
32
+ if (method !== 'post')
33
+ return;
34
+ event.preventDefault();
35
+ const action = new URL(submitter?.hasAttribute('formaction') ? submitter.formAction : own.action);
36
+ const enctype = submitter?.hasAttribute('formenctype') ? submitter.formEnctype : own.enctype;
37
+ const formData = new FormData(form, submitter);
38
+ const controller = new AbortController();
39
+ let cancelled = false;
40
+ const callback = await submit({
41
+ action,
42
+ formData,
43
+ formElement: form,
44
+ controller,
45
+ submitter,
46
+ cancel: () => (cancelled = true)
47
+ });
48
+ if (cancelled)
49
+ return;
50
+ let result;
51
+ try {
52
+ const res = await fetch(action, {
53
+ method: 'POST',
54
+ headers: { accept: 'application/json', 'x-tern-action': 'true' },
55
+ body: enctype === 'multipart/form-data'
56
+ ? formData
57
+ : new URLSearchParams(formData),
58
+ cache: 'no-store',
59
+ credentials: 'same-origin',
60
+ signal: controller.signal
61
+ });
62
+ if (res.headers.get('content-type')?.includes('application/json')) {
63
+ result = await res.json();
64
+ }
65
+ else {
66
+ result = { type: 'error', status: res.status, error: { message: res.statusText || 'Request failed' } };
67
+ }
68
+ }
69
+ catch (err) {
70
+ if (controller.signal.aborted)
71
+ return;
72
+ result = { type: 'error', status: 500, error: { message: err instanceof Error ? err.message : String(err) } };
73
+ }
74
+ const update = (opts) => defaultUpdate(action, form, result, opts);
75
+ if (callback) {
76
+ await callback({ action, formData, formElement: form, result, update });
77
+ }
78
+ else {
79
+ await update();
80
+ }
81
+ }
82
+ form.addEventListener('submit', onSubmit);
83
+ return {
84
+ update(next) {
85
+ submit = next ?? (() => { });
86
+ },
87
+ destroy() {
88
+ form.removeEventListener('submit', onSubmit);
89
+ }
90
+ };
91
+ }
@@ -0,0 +1,14 @@
1
+ import type { ActionResult, ClientManifest, GotoOptions, Payload } from './types.js';
2
+ /** Starts the client. Called by the generated entry module. */
3
+ export declare function start(m: ClientManifest): Promise<void>;
4
+ /** Navigates to href, which may be relative to the current URL. */
5
+ export declare function goto(href: string | URL, opts?: GotoOptions): Promise<void>;
6
+ /** Reruns every load for the current page. */
7
+ export declare function invalidateAll(): Promise<void>;
8
+ /**
9
+ * Loads the data and code for a URL so a later navigation to it is instant.
10
+ * Tern does this automatically for links; see data-tern-preload.
11
+ */
12
+ export declare function preloadData(href: string | URL): Promise<Payload | null>;
13
+ /** Applies a form action result to the page, as `use:enhance` does. */
14
+ export declare function applyAction(result: ActionResult): Promise<void>;