@trylight/app 0.3.2 → 0.3.5

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/README.md CHANGED
@@ -10,7 +10,7 @@ its tables and their typed client, its door, its contracts and its chat.
10
10
  | `@trylight/app/schema` | `entity(name, columns, access)`: a Drizzle table plus `id`, `created_at`, `updated_at`, `created_by`, `field_sources`; `lightChanges` (the audit table); `lightImages` (the app's copy of its logos and photos); `fieldSource` (the `{url, evidence}` schema) |
11
11
  | `@trylight/app` | `createApp<typeof contracts>()` → `app.table(entity)` (`list/get/create/update/delete`, `useList/useInfiniteList/useGet/useCreate/useUpdate/useDelete`, typed by the entity passed; `useList(...).rows` is never undefined), `app.contracts` (`useInvocation`), and `imageSrc(url)` (where a logo or photo loads from) |
12
12
  | `@trylight/app/server` | `createLightServer({ schema, contracts })` → `light.db` (Drizzle), `light.session(request)`, `light.signIn(email, password)`, and the route handlers `light.entities`, `light.image`, `light.invoke.handler`, `light.chat` |
13
- | `@trylight/app/platform` | the door for server functions, framing, analytics/error policies, the pane bridge |
13
+ | `@trylight/app/platform` | the door for server functions, framing, analytics/error policies, the pane bridge, `fetchThroughRestart` (`createStart`'s `serverFns.fetch`) |
14
14
  | `@trylight/app/vite` | `lightDb()`: the dev server syncs `db/schema` into the database on every change |
15
15
  | `light-db` (bin) | `sync`, `migrate`, `check`: the app's `pnpm db:*` scripts |
16
16
 
@@ -41,6 +41,16 @@ text column, case-insensitively, on the server.
41
41
 
42
42
  Joins and aggregates stay typed server functions over `light.db`.
43
43
 
44
+ ### Through a dev server restart
45
+
46
+ Table calls, contract calls and server functions (through
47
+ `fetchThroughRestart`) wait while the gateway answers that the app is starting
48
+ (503 + `x-mii-app-starting`, any method) or, for reads only, while the
49
+ connection fails, for up to `RESTART_WAIT_MS`; then the last answer or error
50
+ is the caller's. The pane hears each request that starts or stops waiting
51
+ (`light:requests-held`: `held` still waiting, `started` for a new one) and swaps
52
+ in a reloaded frame only once none is held.
53
+
44
54
  ### Live data
45
55
 
46
56
  An open page shows other people's and agents' changes without a refresh.
@@ -1,10 +1,16 @@
1
- // Only a path on this app is a valid place to land after signing in. Anything
2
- // absolute (or protocol-relative) would make the login flow an open redirect.
1
+ import { SAME_ORIGIN_BASE } from "../platform/contract.js";
2
+ // Only a path on this app is a valid place to land after signing in. Parsed,
3
+ // not pattern-matched: browsers read `//host`, `/\host` and `/\t/host` as
4
+ // another site. The candidate is returned as given, not normalised: its
5
+ // pathname could read `//host`.
3
6
  export function safeNext(candidate) {
4
7
  if (candidate === null ||
5
8
  !candidate.startsWith("/") ||
6
- candidate.startsWith("//")) {
9
+ // The parser drops tabs and line breaks; a Location header refuses them.
10
+ [...candidate].some((character) => character < " ")) {
7
11
  return "/";
8
12
  }
9
- return candidate;
13
+ return new URL(candidate, SAME_ORIGIN_BASE).origin === SAME_ORIGIN_BASE
14
+ ? candidate
15
+ : "/";
10
16
  }
package/dist/client.js CHANGED
@@ -12,6 +12,7 @@ import { getTableColumns, getTableName } from "drizzle-orm";
12
12
  import { z } from "zod/v4";
13
13
  import { ENTITIES_PATH } from "./platform/contract.js";
14
14
  import { EntityError, LIST_LIMIT, tableQueryKey } from "./entities.js";
15
+ import { fetchThroughRestart } from "./platform/restart.js";
15
16
  export { EntityError, LIST_LIMIT } from "./entities.js";
16
17
  export { siteOf } from "./schema.js";
17
18
  export { imageSrc } from "./images.js";
@@ -354,7 +355,7 @@ function json(body) {
354
355
  }
355
356
  const refusal = z.object({ error: z.string(), code: z.string() });
356
357
  async function call(method, path, init = {}) {
357
- const response = await fetch(path, { ...init, method, credentials: "same-origin" });
358
+ const response = await fetchThroughRestart(path, { ...init, method, credentials: "same-origin" });
358
359
  if (response.ok)
359
360
  return response.json();
360
361
  const read = refusal.safeParse(await response.json().catch(() => null));
@@ -3,6 +3,7 @@
3
3
  // the hook reports how long it has been and the app decides what that means.
4
4
  import { useEffect, useMemo, useState } from "react";
5
5
  import { z } from "zod/v4";
6
+ import { fetchThroughRestart, wait } from "../platform/restart.js";
6
7
  import { errorBody } from "../request-error.js";
7
8
  import { failedRun, InvocationError } from "./error.js";
8
9
  import { INVOKE_BASE_PATH, STORAGE_KEYS } from "../platform/contract.js";
@@ -65,7 +66,7 @@ async function awaitInvocation(started, signal = null, onPoll = unwatched) {
65
66
  async function call(method, path, body, signal) {
66
67
  let response;
67
68
  try {
68
- response = await fetch(`${INVOKE_BASE_PATH}${path}`, {
69
+ response = await fetchThroughRestart(`${INVOKE_BASE_PATH}${path}`, {
69
70
  method,
70
71
  credentials: "same-origin",
71
72
  ...(body === null ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
@@ -90,23 +91,6 @@ async function call(method, path, body, signal) {
90
91
  throw new InvocationError(response.status, "not_the_handler", "The app's invoke routes are not reachable.");
91
92
  }
92
93
  }
93
- function wait(ms, signal) {
94
- return new Promise((resolve, reject) => {
95
- if (signal === null) {
96
- setTimeout(resolve, ms);
97
- return;
98
- }
99
- const abort = () => {
100
- clearTimeout(timer);
101
- reject(signal.reason);
102
- };
103
- const timer = setTimeout(() => {
104
- signal.removeEventListener("abort", abort);
105
- resolve();
106
- }, ms);
107
- signal.addEventListener("abort", abort, { once: true });
108
- });
109
- }
110
94
  // This browser's note of a remembered run. Storage can be blocked or full;
111
95
  // then the run is simply not picked up again. Whose run it is the platform decides:
112
96
  // another person's id reads as not found, and the note is let go of.
@@ -5,17 +5,23 @@ export declare const IMAGE_PATH = "/api/light/image";
5
5
  export declare const BUILD_PATH = "/api/light/build";
6
6
  export declare const INVOKE_BASE_PATH = "/api/mii/invoke";
7
7
  export declare const CHAT_BASE_PATH = "/api/mii/chat";
8
+ /** A base to resolve an app-relative path against when checking it stays on the app; never contacted. */
9
+ export declare const SAME_ORIGIN_BASE = "https://app.invalid";
8
10
  /** The platform's sign-in API, called with an app's own id. */
9
11
  export declare const PLATFORM_AUTH_PATHS: {
10
12
  signIn: string;
11
13
  requestPasswordReset: string;
12
14
  redeemPaneCode: string;
13
15
  config: string;
16
+ /** "Continue with Google / Microsoft": the platform signs the person in and returns with a one-time code. */
17
+ socialSignIn: string;
14
18
  };
15
19
  /** The platform's chat API: an app's chat route forwards any path beneath it, so a new widget route needs no SDK release. */
16
20
  export declare const PLATFORM_CHAT_PATH = "/api/app-chat/v1/chat";
17
21
  /** What this app's chat server forwards beyond the first contract: the widget, served to every app version, offers only these. */
18
22
  export declare const CHAT_FEATURES_HEADER = "X-Mii-Chat-Features";
23
+ /** The gateway's mark on its 503 while the app's server is down: the request never reached the app. */
24
+ export declare const STARTING_HEADER = "x-mii-app-starting";
19
25
  /** Every TanStack query the SDK owns sits under this key. */
20
26
  export declare const QUERY_ROOT = "light";
21
27
  export declare const SESSION_COOKIE = "mii_session";
@@ -39,4 +45,12 @@ export declare const PANE_MESSAGES: {
39
45
  error: "mii-preview:error";
40
46
  picked: "mii-preview:picked";
41
47
  located: "mii-preview:located";
48
+ /** From @trylight/vite-plugin's client: the page mounted, or is about to reload. */
49
+ mounted: "light:mounted";
50
+ willReload: "light:will-reload";
51
+ /** From the gateway's page for a sandbox still waking. */
52
+ starting: "light:starting";
53
+ appError: "app_error";
54
+ /** How many requests are waiting out a dev server restart; the pane swaps in a reload only at zero. */
55
+ requestsHeld: "light:requests-held";
42
56
  };
@@ -11,17 +11,23 @@ export const IMAGE_PATH = `/api/${NAMESPACE}/image`;
11
11
  export const BUILD_PATH = `/api/${NAMESPACE}/build`;
12
12
  export const INVOKE_BASE_PATH = `/api/${LEGACY_NAMESPACE}/invoke`;
13
13
  export const CHAT_BASE_PATH = `/api/${LEGACY_NAMESPACE}/chat`;
14
+ /** A base to resolve an app-relative path against when checking it stays on the app; never contacted. */
15
+ export const SAME_ORIGIN_BASE = "https://app.invalid";
14
16
  /** The platform's sign-in API, called with an app's own id. */
15
17
  export const PLATFORM_AUTH_PATHS = {
16
18
  signIn: "/api/app-auth/sign-in",
17
19
  requestPasswordReset: "/api/app-auth/request-password-reset",
18
20
  redeemPaneCode: "/api/app-auth/redeem",
19
- config: "/api/app-auth/config"
21
+ config: "/api/app-auth/config",
22
+ /** "Continue with Google / Microsoft": the platform signs the person in and returns with a one-time code. */
23
+ socialSignIn: "/api/app-auth/social"
20
24
  };
21
25
  /** The platform's chat API: an app's chat route forwards any path beneath it, so a new widget route needs no SDK release. */
22
26
  export const PLATFORM_CHAT_PATH = "/api/app-chat/v1/chat";
23
27
  /** What this app's chat server forwards beyond the first contract: the widget, served to every app version, offers only these. */
24
28
  export const CHAT_FEATURES_HEADER = "X-Mii-Chat-Features";
29
+ /** The gateway's mark on its 503 while the app's server is down: the request never reached the app. */
30
+ export const STARTING_HEADER = `x-${LEGACY_NAMESPACE}-app-starting`;
25
31
  /** Every TanStack query the SDK owns sits under this key. */
26
32
  export const QUERY_ROOT = NAMESPACE;
27
33
  export const SESSION_COOKIE = `${LEGACY_NAMESPACE}_session`;
@@ -39,6 +45,13 @@ const PANE_PREFIX = `${LEGACY_NAMESPACE}-preview:`;
39
45
  function paneMessage(name) {
40
46
  return `${PANE_PREFIX}${name}`;
41
47
  }
48
+ function namespaced(name) {
49
+ return `${NAMESPACE}:${name}`;
50
+ }
51
+ /** Keeps a message type's literal type in the object below. */
52
+ function exact(value) {
53
+ return value;
54
+ }
42
55
  /** postMessage types between an app framed in the platform's App pane and the pane. */
43
56
  export const PANE_MESSAGES = {
44
57
  hello: paneMessage("hello"),
@@ -48,5 +61,13 @@ export const PANE_MESSAGES = {
48
61
  route: paneMessage("route"),
49
62
  error: paneMessage("error"),
50
63
  picked: paneMessage("picked"),
51
- located: paneMessage("located")
64
+ located: paneMessage("located"),
65
+ /** From @trylight/vite-plugin's client: the page mounted, or is about to reload. */
66
+ mounted: namespaced("mounted"),
67
+ willReload: namespaced("will-reload"),
68
+ /** From the gateway's page for a sandbox still waking. */
69
+ starting: namespaced("starting"),
70
+ appError: exact("app_error"),
71
+ /** How many requests are waiting out a dev server restart; the pane swaps in a reload only at zero. */
72
+ requestsHeld: namespaced("requests-held")
52
73
  };
@@ -9,6 +9,7 @@ import { z } from "zod/v4";
9
9
  import { EntityError, tableQueryKey } from "../entities.js";
10
10
  import { CHANGES_PATH, QUERY_ROOT } from "./contract.js";
11
11
  import { POLL_MS } from "./timing.js";
12
+ import { isStarting } from "./restart.js";
12
13
  const changes = z.object({ cursor: z.string(), changedTables: z.array(z.string()) });
13
14
  /**
14
15
  * Polls `_changes` as a TanStack query. One the server breaks on (or answers
@@ -63,6 +64,8 @@ export async function fetchChanges(since) {
63
64
  // fetch rejects only when no answer came back at all.
64
65
  return "unreachable";
65
66
  }
67
+ if (isStarting(response))
68
+ return "unreachable";
66
69
  if (response.status === 401)
67
70
  return "signed-out";
68
71
  if (!response.ok)
@@ -20,6 +20,7 @@ import { z } from "zod/v4";
20
20
  import { PANE_MESSAGES } from "./contract.js";
21
21
  import { PLAZA_FRAME_ORIGINS } from "./index.js";
22
22
  import { LiveData } from "./live-data-mount.js";
23
+ import { onHeldRequests } from "./restart.js";
23
24
  const PLAZA_ORIGINS = PLAZA_FRAME_ORIGINS;
24
25
  /** The platform → app. Anything else the platform posts is not for this bridge. */
25
26
  const inbound = z.discriminatedUnion("type", [
@@ -163,6 +164,8 @@ export function MiiPaneBridge() {
163
164
  return;
164
165
  window.parent.postMessage({ type: PANE_MESSAGES.route, ...describe() }, plaza);
165
166
  });
167
+ // The pane swaps in a reload only once nothing here is held.
168
+ const stopHeld = onHeldRequests(({ held, started }) => send({ type: PANE_MESSAGES.requestsHeld, held, started }));
166
169
  document.body.appendChild(outline);
167
170
  window.addEventListener("error", onError);
168
171
  window.addEventListener("unhandledrejection", onRejection);
@@ -171,6 +174,7 @@ export function MiiPaneBridge() {
171
174
  document.addEventListener("click", onClick, true);
172
175
  return () => {
173
176
  stopRouteWatch();
177
+ stopHeld();
174
178
  window.removeEventListener("error", onError);
175
179
  window.removeEventListener("unhandledrejection", onRejection);
176
180
  window.removeEventListener("message", onMessage);
@@ -0,0 +1,18 @@
1
+ export declare const isStarting: (response: Response) => boolean;
2
+ /** How many requests are waiting out a restart, and whether this change is one starting to. */
3
+ type HeldChange = {
4
+ readonly held: number;
5
+ readonly started: boolean;
6
+ };
7
+ /** Hears each request that starts or stops waiting out a restart. */
8
+ export declare function onHeldRequests(listener: (change: HeldChange) => void): () => void;
9
+ /**
10
+ * `fetch`, held through a restart: the gateway's starting answer is retried
11
+ * for any method, a failed connection only for a read (a write may have
12
+ * reached the app). Anything else, or the last try at RESTART_WAIT_MS, is
13
+ * the caller's as it came.
14
+ */
15
+ export declare function fetchThroughRestart(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
16
+ /** Resolves after `ms`, or rejects with the signal's reason once it aborts. */
17
+ export declare function wait(ms: number, signal: AbortSignal | null): Promise<void>;
18
+ export {};
@@ -0,0 +1,70 @@
1
+ // A request that meets the app's dev server restarting waits for it to come back.
2
+ import { STARTING_HEADER } from "./contract.js";
3
+ import { RESTART_FIRST_RETRY_MS, RESTART_MAX_RETRY_MS, RESTART_WAIT_MS } from "./timing.js";
4
+ export const isStarting = (response) => response.status === 503 && response.headers.has(STARTING_HEADER);
5
+ // File-local: HTTP's safe methods, which only this retry decision needs; resending one cannot repeat a write.
6
+ const SAFE_TO_RESEND = new Set(["GET", "HEAD", "OPTIONS"]);
7
+ let held = 0;
8
+ const listeners = new Set();
9
+ /** Hears each request that starts or stops waiting out a restart. */
10
+ export function onHeldRequests(listener) {
11
+ listeners.add(listener);
12
+ return () => listeners.delete(listener);
13
+ }
14
+ function hold(started) {
15
+ held += started ? 1 : -1;
16
+ for (const listener of listeners)
17
+ listener({ held, started });
18
+ }
19
+ /**
20
+ * `fetch`, held through a restart: the gateway's starting answer is retried
21
+ * for any method, a failed connection only for a read (a write may have
22
+ * reached the app). Anything else, or the last try at RESTART_WAIT_MS, is
23
+ * the caller's as it came.
24
+ */
25
+ export async function fetchThroughRestart(input, init = {}) {
26
+ const { method = input instanceof Request ? input.method : "GET", signal = null } = init;
27
+ const giveUpAt = Date.now() + RESTART_WAIT_MS;
28
+ let waiting = false;
29
+ try {
30
+ for (let attempt = 0;; attempt += 1) {
31
+ try {
32
+ const response = await fetch(input instanceof Request ? input.clone() : input, init);
33
+ if (!isStarting(response) || Date.now() >= giveUpAt)
34
+ return response;
35
+ }
36
+ catch (error) {
37
+ // fetch rejects with a TypeError only when no answer came back; an abort is the caller's.
38
+ if (!(error instanceof TypeError) || !SAFE_TO_RESEND.has(method.toUpperCase()) || Date.now() >= giveUpAt)
39
+ throw error;
40
+ }
41
+ if (!waiting) {
42
+ waiting = true;
43
+ hold(true);
44
+ }
45
+ await wait(Math.min(RESTART_FIRST_RETRY_MS * 2 ** attempt, RESTART_MAX_RETRY_MS), signal);
46
+ }
47
+ }
48
+ finally {
49
+ if (waiting)
50
+ hold(false);
51
+ }
52
+ }
53
+ /** Resolves after `ms`, or rejects with the signal's reason once it aborts. */
54
+ export function wait(ms, signal) {
55
+ return new Promise((resolve, reject) => {
56
+ if (signal === null) {
57
+ setTimeout(resolve, ms);
58
+ return;
59
+ }
60
+ const abort = () => {
61
+ clearTimeout(timer);
62
+ reject(signal.reason);
63
+ };
64
+ const timer = setTimeout(() => {
65
+ signal.removeEventListener("abort", abort);
66
+ resolve();
67
+ }, ms);
68
+ signal.addEventListener("abort", abort, { once: true });
69
+ });
70
+ }
@@ -6,6 +6,11 @@ export declare const CHECK_MS = 60000;
6
6
  export declare const IDLE_MS = 5000;
7
7
  /** After a reload: how long a list may take to be tall enough to scroll back; past it, it stays at the top. */
8
8
  export declare const SCROLL_RESTORE_MS = 5000;
9
+ /** A request meeting a dev server restart waits this long for it at most, then fails with what it got. */
10
+ export declare const RESTART_WAIT_MS = 30000;
11
+ /** Backoff between a held request's tries: from the first, doubling up to the max. */
12
+ export declare const RESTART_FIRST_RETRY_MS = 250;
13
+ export declare const RESTART_MAX_RETRY_MS = 1000;
9
14
  /** A contract read that failed: how long before asking again. */
10
15
  export declare const INVOCATION_RETRY_MS = 1500;
11
16
  /** How long a page remembers a contract invocation it started, across reloads. */
@@ -7,6 +7,11 @@ export const CHECK_MS = 60_000;
7
7
  export const IDLE_MS = 5_000;
8
8
  /** After a reload: how long a list may take to be tall enough to scroll back; past it, it stays at the top. */
9
9
  export const SCROLL_RESTORE_MS = 5_000;
10
+ /** A request meeting a dev server restart waits this long for it at most, then fails with what it got. */
11
+ export const RESTART_WAIT_MS = 30_000;
12
+ /** Backoff between a held request's tries: from the first, doubling up to the max. */
13
+ export const RESTART_FIRST_RETRY_MS = 250;
14
+ export const RESTART_MAX_RETRY_MS = 1_000;
10
15
  /** A contract read that failed: how long before asking again. */
11
16
  export const INVOCATION_RETRY_MS = 1500;
12
17
  /** How long a page remembers a contract invocation it started, across reloads. */
@@ -1,3 +1,4 @@
1
1
  export { FRAME_ANCESTORS, isAgentBrowser, mayCallServerFn, miiAnalyticsPolicy, miiErrorPolicy, PLAZA_FRAME_ORIGINS, type ServerFnName } from "./platform/index.js";
2
2
  export { MiiPaneBridge } from "./platform/pane-bridge.js";
3
3
  export { ReloadOnNewBuild } from "./platform/new-build-mount.js";
4
+ export { fetchThroughRestart } from "./platform/restart.js";
package/dist/platform.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // The platform contract a page and the global middleware need: framing by
2
2
  // the platform's App pane, the server-function door, analytics and error policies,
3
- // the pane bridge, and moving an open page onto a new build.
3
+ // the pane bridge, moving an open page onto a new build, and the fetch that
4
+ // waits out a dev server restart.
4
5
  export { FRAME_ANCESTORS, isAgentBrowser, mayCallServerFn, miiAnalyticsPolicy, miiErrorPolicy, PLAZA_FRAME_ORIGINS } from "./platform/index.js";
5
6
  export { MiiPaneBridge } from "./platform/pane-bridge.js";
6
7
  export { ReloadOnNewBuild } from "./platform/new-build-mount.js";
8
+ export { fetchThroughRestart } from "./platform/restart.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trylight/app",
3
- "version": "0.3.2",
3
+ "version": "0.3.5",
4
4
  "description": "The Light SDK every v2 org app depends on: entities (Drizzle tables with a typed client), sign-in, contracts and in-app chat.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -12,7 +12,6 @@
12
12
  "url": "https://github.com/shubh9/mii.git",
13
13
  "directory": "packages/app-sdk"
14
14
  },
15
- "packageManager": "pnpm@11.0.9",
16
15
  "files": [
17
16
  "dist",
18
17
  "bin"
@@ -42,15 +41,6 @@
42
41
  "default": "./dist/vite.js"
43
42
  }
44
43
  },
45
- "scripts": {
46
- "build": "rm -rf dist && tsc -p tsconfig.build.json",
47
- "typecheck": "tsc --noEmit",
48
- "lint": "eslint src test --max-warnings 0",
49
- "test": "node --test --import tsx \"test/**/*.test.ts\"",
50
- "check": "tsx scripts/write-brand.ts --check && pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run build",
51
- "prepack": "pnpm run build",
52
- "brand": "tsx scripts/write-brand.ts"
53
- },
54
44
  "peerDependencies": {
55
45
  "@tanstack/react-query": ">=5.69",
56
46
  "@tanstack/react-router": ">=1.170",
@@ -84,5 +74,13 @@
84
74
  "typescript-eslint": "^8.70.1",
85
75
  "vite": "^8.2.0",
86
76
  "zod": "^4.6.5"
77
+ },
78
+ "scripts": {
79
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
80
+ "typecheck": "tsc --noEmit",
81
+ "lint": "eslint src test --max-warnings 0",
82
+ "test": "node --test --import tsx \"test/**/*.test.ts\"",
83
+ "check": "tsx scripts/write-brand.ts --check && pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run build",
84
+ "brand": "tsx scripts/write-brand.ts"
87
85
  }
88
- }
86
+ }