@trylight/app 0.3.1 → 0.3.2

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.
@@ -1,7 +1,7 @@
1
1
  import type { AuthContext } from "./context.js";
2
2
  import { type PlazaSession } from "./plaza.js";
3
3
  /** The cookie holding this app's own session. Nothing of the platform's is read here. */
4
- export declare const SESSION_COOKIE = "mii_session";
4
+ export { SESSION_COOKIE } from "../platform/contract.js";
5
5
  /**
6
6
  * How often a session asks the platform whether it still stands. Someone removed from
7
7
  * the org is signed out of the app within this long; their chat stops at once,
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { checkPlazaSession, revokePlazaSession } from "./plaza.js";
3
3
  /** The cookie holding this app's own session. Nothing of the platform's is read here. */
4
- export const SESSION_COOKIE = "mii_session";
4
+ export { SESSION_COOKIE } from "../platform/contract.js";
5
5
  /**
6
6
  * How often a session asks the platform whether it still stands. Someone removed from
7
7
  * the org is signed out of the app within this long; their chat stops at once,
package/dist/auth/mode.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod/v4";
2
2
  import { miiAuthConfig } from "./config.js";
3
+ import { PLATFORM_AUTH_PATHS } from "../platform/contract.js";
3
4
  // Who may open this app, as set in the platform (Settings → Workspaces → "Who can open
4
5
  // it"). Read from the platform rather than baked into the build so the switch takes
5
6
  // effect on the next request, not the next deploy.
@@ -40,7 +41,7 @@ export async function getAuthMode() {
40
41
  }
41
42
  async function fetchMode() {
42
43
  const { plazaUrl, appId } = miiAuthConfig();
43
- const response = await fetch(`${plazaUrl}/api/app-auth/config?app_id=${encodeURIComponent(appId)}`, { cache: "no-store" });
44
+ const response = await fetch(`${plazaUrl}${PLATFORM_AUTH_PATHS.config}?app_id=${encodeURIComponent(appId)}`, { cache: "no-store" });
44
45
  if (!response.ok) {
45
46
  throw new Error(`The platform's app-auth config answered ${response.status}.`);
46
47
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod/v4";
2
2
  import { miiAuthConfig } from "./config.js";
3
+ import { PLATFORM_AUTH_PATHS } from "../platform/contract.js";
3
4
  /**
4
5
  * Check an email and password with the platform, and get back who the person is and
5
6
  * the session the platform signed for them.
@@ -8,7 +9,7 @@ export async function signInWithPlaza(email, password) {
8
9
  const { plazaUrl, appId } = miiAuthConfig();
9
10
  let response;
10
11
  try {
11
- response = await fetch(`${plazaUrl}/api/app-auth/sign-in`, {
12
+ response = await fetch(`${plazaUrl}${PLATFORM_AUTH_PATHS.signIn}`, {
12
13
  method: "POST",
13
14
  headers: { "Content-Type": "application/json" },
14
15
  body: JSON.stringify({ appId, email, password }),
@@ -50,7 +51,7 @@ export async function requestPasswordResetWithPlaza(email, returnTo) {
50
51
  const { plazaUrl, appId } = miiAuthConfig();
51
52
  let response;
52
53
  try {
53
- response = await fetch(`${plazaUrl}/api/app-auth/request-password-reset`, {
54
+ response = await fetch(`${plazaUrl}${PLATFORM_AUTH_PATHS.requestPasswordReset}`, {
54
55
  method: "POST",
55
56
  headers: { "Content-Type": "application/json" },
56
57
  body: JSON.stringify({ appId, email, returnTo }),
@@ -80,7 +81,7 @@ export async function requestPasswordResetWithPlaza(email, returnTo) {
80
81
  */
81
82
  export async function redeemWithPlaza(code) {
82
83
  const { plazaUrl, appId } = miiAuthConfig();
83
- const response = await fetch(`${plazaUrl}/api/app-auth/redeem`, {
84
+ const response = await fetch(`${plazaUrl}${PLATFORM_AUTH_PATHS.redeemPaneCode}`, {
84
85
  method: "POST",
85
86
  headers: { "Content-Type": "application/json" },
86
87
  body: JSON.stringify({ appId, code }),
@@ -1,10 +1,3 @@
1
- /** Where an app mounts its chat routes, and where the widget calls them. */
2
- export declare const CHAT_BASE_PATH = "/api/mii/chat";
3
- /**
4
- * What the app's chat server can do beyond the first contract, listed on its
5
- * answers. The platform serves the widget to apps on every runtime version, so the
6
- * widget offers only what this app's server says it forwards.
7
- */
8
- export declare const CHAT_FEATURES_HEADER = "X-Mii-Chat-Features";
1
+ /** Features listed in CHAT_FEATURES_HEADER. */
9
2
  export declare const REACTIONS_FEATURE = "reactions";
10
3
  export declare const CREDITS_FEATURE = "credits";
@@ -1,10 +1,3 @@
1
- /** Where an app mounts its chat routes, and where the widget calls them. */
2
- export const CHAT_BASE_PATH = "/api/mii/chat";
3
- /**
4
- * What the app's chat server can do beyond the first contract, listed on its
5
- * answers. The platform serves the widget to apps on every runtime version, so the
6
- * widget offers only what this app's server says it forwards.
7
- */
8
- export const CHAT_FEATURES_HEADER = "X-Mii-Chat-Features";
1
+ /** Features listed in CHAT_FEATURES_HEADER. */
9
2
  export const REACTIONS_FEATURE = "reactions";
10
3
  export const CREDITS_FEATURE = "credits";
@@ -15,12 +15,8 @@
15
15
  // the platform signed when the person signed in. The platform checks that token and the
16
16
  // person's membership on every request.
17
17
  import { miiAuthConfig } from "../auth/config.js";
18
- import { CHAT_BASE_PATH, CHAT_FEATURES_HEADER, CREDITS_FEATURE, REACTIONS_FEATURE } from "./paths.js";
19
- // The platform's browser-facing chat routes all sit under this one prefix, and this
20
- // server forwards any path beneath it: a widget route the platform adds needs no
21
- // release of this package. The platform's server-only routes (session) are outside it,
22
- // and each route under it validates its own ids.
23
- const PLAZA_CHAT_PATH = "/api/app-chat/v1/chat";
18
+ import { CHAT_BASE_PATH, CHAT_FEATURES_HEADER, PLATFORM_CHAT_PATH } from "../platform/contract.js";
19
+ import { CREDITS_FEATURE, REACTIONS_FEATURE } from "./paths.js";
24
20
  // Plain segments only: no dots, no percent-encoding, no empty segment. A path
25
21
  // that could climb out of the platform's prefix (`..`, `%2e%2e`, `%2f`, `//`) never
26
22
  // leaves this server.
@@ -28,7 +24,7 @@ const FORWARDABLE_PATH = /^(\/[A-Za-z0-9_-]+)+$/;
28
24
  // What the thread's poll and its pages may carry through. Everything else in
29
25
  // the query string is dropped rather than forwarded.
30
26
  const FORWARDED_QUERY = ["after", "afterId", "limit", "before", "beforeId"];
31
- export function createMiiChatHandler(auth, { reportError = (error, context) => console.error("mii-chat error", context, error) } = {}) {
27
+ export function createMiiChatHandler(auth, { reportError = (error, context) => console.error("chat route error", context, error) } = {}) {
32
28
  return async (request) => {
33
29
  const url = new URL(request.url);
34
30
  if (!url.pathname.startsWith(`${CHAT_BASE_PATH}/`)) {
@@ -56,7 +52,7 @@ export function createMiiChatHandler(auth, { reportError = (error, context) => c
56
52
  return refuse(401, "sign_in_again", "Sign out and back in to use it here.");
57
53
  }
58
54
  const { plazaUrl } = miiAuthConfig();
59
- const target = new URL(`${plazaUrl}${PLAZA_CHAT_PATH}${path}`);
55
+ const target = new URL(`${plazaUrl}${PLATFORM_CHAT_PATH}${path}`);
60
56
  for (const key of FORWARDED_QUERY) {
61
57
  const value = url.searchParams.get(key);
62
58
  if (value !== null)
@@ -83,7 +79,7 @@ export function createMiiChatHandler(auth, { reportError = (error, context) => c
83
79
  answer = await fetch(target, init);
84
80
  }
85
81
  catch (error) {
86
- reportError(error, { where: "mii-chat", step: "forward" });
82
+ reportError(error, { where: "chat-route", step: "forward" });
87
83
  return refuse(502, "unavailable", "Couldn't reach the chat. Try again in a moment.");
88
84
  }
89
85
  // The platform no longer stands behind this session: the token is no good, or the
@@ -93,7 +89,7 @@ export function createMiiChatHandler(auth, { reportError = (error, context) => c
93
89
  await auth.revokeSession(token, answer.status === 403 ? "not_a_member" : "token_rejected");
94
90
  }
95
91
  if (answer.status >= 500) {
96
- reportError(new Error(`Platform chat answered ${answer.status}: ${(await answer.clone().text()).slice(0, 300)}`), { where: "mii-chat", step: "plaza" });
92
+ reportError(new Error(`Platform chat answered ${answer.status}: ${(await answer.clone().text()).slice(0, 300)}`), { where: "chat-route", step: "plaza" });
97
93
  }
98
94
  // An avatar may be cached for the person; chat data never is.
99
95
  const cacheControl = answer.headers.get("cache-control");
package/dist/client.js CHANGED
@@ -10,7 +10,8 @@ import { useEffect, useMemo, useState } from "react";
10
10
  import { createInvoker } from "./invoke/index.js";
11
11
  import { getTableColumns, getTableName } from "drizzle-orm";
12
12
  import { z } from "zod/v4";
13
- import { ENTITIES_PATH, EntityError, LIST_LIMIT, tableQueryKey } from "./entities.js";
13
+ import { ENTITIES_PATH } from "./platform/contract.js";
14
+ import { EntityError, LIST_LIMIT, tableQueryKey } from "./entities.js";
14
15
  export { EntityError, LIST_LIMIT } from "./entities.js";
15
16
  export { siteOf } from "./schema.js";
16
17
  export { imageSrc } from "./images.js";
@@ -1,8 +1,5 @@
1
1
  import type { Entity, PlatformColumn } from "./schema.js";
2
- export declare const ENTITIES_PATH = "/api/light/entities";
3
- /** Which tables changed since a cursor (open pages' poll): `?since=<cursor>` → {cursor, changedTables}. */
4
- export declare const CHANGES_PATH = "/api/light/entities/_changes";
5
- /** The TanStack Query key everything cached from one table sits under; a change to the table invalidates it. */
2
+ /** Everything cached from one table sits under this key; a change to the table invalidates it. */
6
3
  export declare const tableQueryKey: (table: string) => string[];
7
4
  /** The most rows one list returns; page past it with `offset` (useInfiniteList does). */
8
5
  export declare const LIST_LIMIT = 1000;
package/dist/entities.js CHANGED
@@ -1,8 +1,8 @@
1
- export const ENTITIES_PATH = "/api/light/entities";
2
- /** Which tables changed since a cursor (open pages' poll): `?since=<cursor>` → {cursor, changedTables}. */
3
- export const CHANGES_PATH = `${ENTITIES_PATH}/_changes`;
4
- /** The TanStack Query key everything cached from one table sits under; a change to the table invalidates it. */
5
- export const tableQueryKey = (table) => ["light", `${ENTITIES_PATH}/${table}`];
1
+ // What the browser client and the entities route agree on: the path, the
2
+ // shapes of a request, and the row types a schema gives them.
3
+ import { ENTITIES_PATH, QUERY_ROOT } from "./platform/contract.js";
4
+ /** Everything cached from one table sits under this key; a change to the table invalidates it. */
5
+ export const tableQueryKey = (table) => [QUERY_ROOT, `${ENTITIES_PATH}/${table}`];
6
6
  /** The most rows one list returns; page past it with `offset` (useInfiniteList does). */
7
7
  export const LIST_LIMIT = 1000;
8
8
  /** A refused or failed entities call, with the route's own reason. */
package/dist/images.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- export declare const IMAGE_PATH = "/api/light/image";
2
1
  /**
3
2
  * Where a row's photo or logo loads from: the app's own copy (kept from the
4
3
  * first view on) for an address on the web, the value itself for one of the
package/dist/images.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // What the browser and the image route agree on: where a row's photo or logo
2
2
  // loads from. Nothing here may touch a database or the DOM.
3
+ import { IMAGE_PATH } from "./platform/contract.js";
3
4
  import { webUrl } from "./schema.js";
4
- export const IMAGE_PATH = "/api/light/image";
5
5
  /**
6
6
  * Where a row's photo or logo loads from: the app's own copy (kept from the
7
7
  * first view on) for an address on the web, the value itself for one of the
@@ -4,10 +4,6 @@ export { InvocationError };
4
4
  export type { Invocation, InvocationState } from "./contracts.js";
5
5
  /** How long one read asks the platform to hold a pending invocation; the platform clamps it to its own limit. */
6
6
  export declare const INVOCATION_WAIT_SECONDS = 60;
7
- /** The pause after a read that failed before asking again. */
8
- export declare const INVOCATION_RETRY_MS = 1500;
9
- /** How long a run started with `remember` is picked up again in this browser. */
10
- export declare const INVOCATION_REMEMBER_MS: number;
11
7
  type Result = Record<string, unknown>;
12
8
  /**
13
9
  * `id` and `input` are null until the platform has the invocation. `input` is the
@@ -5,16 +5,12 @@ import { useEffect, useMemo, useState } from "react";
5
5
  import { z } from "zod/v4";
6
6
  import { errorBody } from "../request-error.js";
7
7
  import { failedRun, InvocationError } from "./error.js";
8
- import { INVOKE_BASE_PATH } from "./paths.js";
8
+ import { INVOKE_BASE_PATH, STORAGE_KEYS } from "../platform/contract.js";
9
+ import { INVOCATION_REMEMBER_MS, INVOCATION_RETRY_MS } from "../platform/timing.js";
9
10
  export { InvocationError };
10
11
  /** How long one read asks the platform to hold a pending invocation; the platform clamps it to its own limit. */
11
12
  export const INVOCATION_WAIT_SECONDS = 60;
12
- /** The pause after a read that failed before asking again. */
13
- export const INVOCATION_RETRY_MS = 1500;
14
- /** How long a run started with `remember` is picked up again in this browser. */
15
- export const INVOCATION_REMEMBER_MS = 24 * 60 * 60 * 1000;
16
13
  const ELAPSED_TICK_MS = 1000;
17
- const REMEMBERED_PREFIX = "mii-invocation:";
18
14
  export function createInvoker() {
19
15
  return {
20
16
  async invoke(key, input, signal = null) {
@@ -49,7 +45,7 @@ async function readAnswered(id, holdSeconds, signal = null, onPoll = unwatched)
49
45
  // invocation, which carries on either way.
50
46
  if (!(error instanceof InvocationError) || (error.status !== 0 && error.status < 500))
51
47
  throw error;
52
- console.error("mii-invoke: a read failed; trying again", error);
48
+ console.error("invoke: a read failed; trying again", error);
53
49
  onPoll(null, error);
54
50
  await wait(INVOCATION_RETRY_MS, signal);
55
51
  }
@@ -162,7 +158,7 @@ const IDLE = { state: "idle" };
162
158
  * With `remember`, `restore()` picks up the run this browser last started with it.
163
159
  */
164
160
  export function createInvocationController(key, onStatus, { remember = null } = {}) {
165
- const name = remember === null ? null : `${REMEMBERED_PREFIX}${key}:${remember}`;
161
+ const name = remember === null ? null : `${STORAGE_KEYS.invocationPrefix}${key}:${remember}`;
166
162
  // The run this button follows. `note` goes false once it is replaced or
167
163
  // reset, so a start still in flight is never noted; an unmount leaves it.
168
164
  let current = null;
@@ -6,7 +6,7 @@ import { isSameOrigin, readCookie } from "../chat/server.js";
6
6
  import { errorBody } from "../request-error.js";
7
7
  import { isUuid } from "../uuid.js";
8
8
  import { failedRun, InvocationError } from "./error.js";
9
- import { INVOKE_BASE_PATH } from "./paths.js";
9
+ import { INVOKE_BASE_PATH } from "../platform/contract.js";
10
10
  export { InvocationError };
11
11
  function routeOf(request) {
12
12
  const { pathname } = new URL(request.url);
@@ -125,7 +125,7 @@ async function askPlaza(auth, person, call) {
125
125
  // refinement or a transform. The app's own Zod has the last word.
126
126
  function resultMismatch(id, error, reportError) {
127
127
  const problem = `The result of invocation ${id} does not match this app's output schema:\n${z.prettifyError(error)}`;
128
- reportError(new Error(problem), { where: "mii-invoke", step: "result" });
128
+ reportError(new Error(problem), { where: "invoke-route", step: "result" });
129
129
  return new InvocationError(422, "result_mismatch", problem);
130
130
  }
131
131
  /**
@@ -133,7 +133,7 @@ function resultMismatch(id, error, reportError) {
133
133
  * Every output is converted to JSON Schema here, once, so one with no JSON
134
134
  * Schema form stops the server starting, not someone's press of the button.
135
135
  */
136
- export function createMiiInvoke(auth, contracts, { reportError = (error, context) => console.error("mii-invoke error", context, error) } = {}) {
136
+ export function createMiiInvoke(auth, contracts, { reportError = (error, context) => console.error("invoke route error", context, error) } = {}) {
137
137
  const declared = new Map(Object.entries(contracts).map(([key, contract]) => [key, declare(key, contract)]));
138
138
  const handler = async (request) => {
139
139
  const route = routeOf(request);
@@ -168,13 +168,13 @@ export function createMiiInvoke(auth, contracts, { reportError = (error, context
168
168
  }
169
169
  catch (error) {
170
170
  if (!request.signal.aborted)
171
- reportError(error, { where: "mii-invoke", step: "forward" });
171
+ reportError(error, { where: "invoke-route", step: "forward" });
172
172
  return refuse(502, "unavailable", "Couldn't reach the platform. Try again in a moment.");
173
173
  }
174
174
  if (!answer.ok) {
175
175
  if (answer.status >= 500) {
176
176
  reportError(new Error(`Platform answered ${answer.status}: ${(await answer.clone().text()).slice(0, 300)}`), {
177
- where: "mii-invoke",
177
+ where: "invoke-route",
178
178
  step: "plaza"
179
179
  });
180
180
  }
@@ -0,0 +1,42 @@
1
+ export declare const ENTITIES_PATH = "/api/light/entities";
2
+ export declare const CHANGES_PATH = "/api/light/entities/_changes";
3
+ export declare const IMAGE_PATH = "/api/light/image";
4
+ /** The template's src/routes/api/light/build.ts. */
5
+ export declare const BUILD_PATH = "/api/light/build";
6
+ export declare const INVOKE_BASE_PATH = "/api/mii/invoke";
7
+ export declare const CHAT_BASE_PATH = "/api/mii/chat";
8
+ /** The platform's sign-in API, called with an app's own id. */
9
+ export declare const PLATFORM_AUTH_PATHS: {
10
+ signIn: string;
11
+ requestPasswordReset: string;
12
+ redeemPaneCode: string;
13
+ config: string;
14
+ };
15
+ /** 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
+ export declare const PLATFORM_CHAT_PATH = "/api/app-chat/v1/chat";
17
+ /** What this app's chat server forwards beyond the first contract: the widget, served to every app version, offers only these. */
18
+ export declare const CHAT_FEATURES_HEADER = "X-Mii-Chat-Features";
19
+ /** Every TanStack query the SDK owns sits under this key. */
20
+ export declare const QUERY_ROOT = "light";
21
+ export declare const SESSION_COOKIE = "mii_session";
22
+ export declare const STORAGE_KEYS: {
23
+ /** sessionStorage: the build this tab already reloaded for after a missing chunk, so a second miss on it can't loop. */
24
+ chunkRetryBuildId: string;
25
+ /** sessionStorage: scroll positions saved just before a reload onto a new build. */
26
+ scrollBeforeReload: string;
27
+ /** localStorage prefix: a contract invocation remembered across reloads. */
28
+ invocationPrefix: string;
29
+ /** localStorage, and the URL parameter that sets it: this browser is the org's own automation. */
30
+ agentBrowser: string;
31
+ };
32
+ /** postMessage types between an app framed in the platform's App pane and the pane. */
33
+ export declare const PANE_MESSAGES: {
34
+ hello: "mii-preview:hello";
35
+ select: "mii-preview:select";
36
+ locate: "mii-preview:locate";
37
+ ready: "mii-preview:ready";
38
+ route: "mii-preview:route";
39
+ error: "mii-preview:error";
40
+ picked: "mii-preview:picked";
41
+ located: "mii-preview:located";
42
+ };
@@ -0,0 +1,52 @@
1
+ // The names an app's pages, its server and the platform agree on. Deployed
2
+ // apps and the platform's pane depend on every value here: rename freely, never
3
+ // change a value without both sides moving.
4
+ const NAMESPACE = "light";
5
+ /** The platform's former name: the pane protocol, the session cookie and two routes still carry it. */
6
+ const LEGACY_NAMESPACE = "mii";
7
+ export const ENTITIES_PATH = `/api/${NAMESPACE}/entities`;
8
+ export const CHANGES_PATH = `${ENTITIES_PATH}/_changes`;
9
+ export const IMAGE_PATH = `/api/${NAMESPACE}/image`;
10
+ /** The template's src/routes/api/light/build.ts. */
11
+ export const BUILD_PATH = `/api/${NAMESPACE}/build`;
12
+ export const INVOKE_BASE_PATH = `/api/${LEGACY_NAMESPACE}/invoke`;
13
+ export const CHAT_BASE_PATH = `/api/${LEGACY_NAMESPACE}/chat`;
14
+ /** The platform's sign-in API, called with an app's own id. */
15
+ export const PLATFORM_AUTH_PATHS = {
16
+ signIn: "/api/app-auth/sign-in",
17
+ requestPasswordReset: "/api/app-auth/request-password-reset",
18
+ redeemPaneCode: "/api/app-auth/redeem",
19
+ config: "/api/app-auth/config"
20
+ };
21
+ /** 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
+ export const PLATFORM_CHAT_PATH = "/api/app-chat/v1/chat";
23
+ /** What this app's chat server forwards beyond the first contract: the widget, served to every app version, offers only these. */
24
+ export const CHAT_FEATURES_HEADER = "X-Mii-Chat-Features";
25
+ /** Every TanStack query the SDK owns sits under this key. */
26
+ export const QUERY_ROOT = NAMESPACE;
27
+ export const SESSION_COOKIE = `${LEGACY_NAMESPACE}_session`;
28
+ export const STORAGE_KEYS = {
29
+ /** sessionStorage: the build this tab already reloaded for after a missing chunk, so a second miss on it can't loop. */
30
+ chunkRetryBuildId: `${NAMESPACE}:chunk-retry-build-id`,
31
+ /** sessionStorage: scroll positions saved just before a reload onto a new build. */
32
+ scrollBeforeReload: `${NAMESPACE}:scroll-before-reload`,
33
+ /** localStorage prefix: a contract invocation remembered across reloads. */
34
+ invocationPrefix: `${LEGACY_NAMESPACE}-invocation:`,
35
+ /** localStorage, and the URL parameter that sets it: this browser is the org's own automation. */
36
+ agentBrowser: `${LEGACY_NAMESPACE}_agent`
37
+ };
38
+ const PANE_PREFIX = `${LEGACY_NAMESPACE}-preview:`;
39
+ function paneMessage(name) {
40
+ return `${PANE_PREFIX}${name}`;
41
+ }
42
+ /** postMessage types between an app framed in the platform's App pane and the pane. */
43
+ export const PANE_MESSAGES = {
44
+ hello: paneMessage("hello"),
45
+ select: paneMessage("select"),
46
+ locate: paneMessage("locate"),
47
+ ready: paneMessage("ready"),
48
+ route: paneMessage("route"),
49
+ error: paneMessage("error"),
50
+ picked: paneMessage("picked"),
51
+ located: paneMessage("located")
52
+ };
@@ -5,6 +5,7 @@
5
5
  // events that pollute product data). Frameworks come and go around this file;
6
6
  // nothing in it may import one.
7
7
  import { APP_ORIGINS } from "../brand.generated.js";
8
+ import { STORAGE_KEYS } from "./contract.js";
8
9
  /**
9
10
  * Platform origins allowed to frame an org app AND to drive its preview
10
11
  * bridge. One list on purpose: who may frame the app and who may talk to it
@@ -66,11 +67,11 @@ export function miiAnalyticsPolicy({ vercelEnv = "development", ingestHost, forc
66
67
  */
67
68
  export function isAgentBrowser(win) {
68
69
  try {
69
- if (new URLSearchParams(win.location.search).has("mii_agent")) {
70
- win.localStorage.setItem("mii_agent", "1");
70
+ if (new URLSearchParams(win.location.search).has(STORAGE_KEYS.agentBrowser)) {
71
+ win.localStorage.setItem(STORAGE_KEYS.agentBrowser, "1");
71
72
  }
72
73
  return (win.navigator.webdriver === true ||
73
- win.localStorage.getItem("mii_agent") === "1");
74
+ win.localStorage.getItem(STORAGE_KEYS.agentBrowser) === "1");
74
75
  }
75
76
  catch {
76
77
  // localStorage can be unavailable (privacy modes); never break the app.
@@ -0,0 +1,5 @@
1
+ export declare function keepScroll(): void;
2
+ export declare function restoreScroll(): void;
3
+ export declare function takeSession(key: string): string | null;
4
+ export declare function readSession(key: string): string | null;
5
+ export declare function writeSession(key: string, value: string): void;
@@ -0,0 +1,75 @@
1
+ // The router restores the window's scroll before a list inside the page has its rows; these wait until each is tall enough.
2
+ import { z } from "zod/v4";
3
+ import { STORAGE_KEYS } from "./contract.js";
4
+ import { SCROLL_RESTORE_MS } from "./timing.js";
5
+ const scrollPositions = z.array(z.object({ selector: z.string(), top: z.number() }));
6
+ function pathTo(element) {
7
+ const parts = [];
8
+ for (let node = element; node !== null; node = node.parentElement) {
9
+ const at = node.parentElement === null ? 1 : [...node.parentElement.children].indexOf(node) + 1;
10
+ parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${at})`);
11
+ }
12
+ return `:root > ${parts.slice(1).join(" > ")}`;
13
+ }
14
+ export function keepScroll() {
15
+ const scrolled = [...document.body.querySelectorAll("*")].filter((element) => element.scrollTop > 0);
16
+ writeSession(STORAGE_KEYS.scrollBeforeReload, JSON.stringify(scrolled.map((element) => ({ selector: pathTo(element), top: element.scrollTop }))));
17
+ }
18
+ export function restoreScroll() {
19
+ const saved = takeSession(STORAGE_KEYS.scrollBeforeReload);
20
+ if (saved === null)
21
+ return;
22
+ // Another build's shape: start at the top.
23
+ const read = scrollPositions.safeParse(parseJson(saved));
24
+ if (!read.success)
25
+ return;
26
+ let pending = read.data;
27
+ const until = performance.now() + SCROLL_RESTORE_MS;
28
+ const step = () => {
29
+ pending = pending.filter(({ selector, top }) => {
30
+ const element = document.querySelector(selector);
31
+ if (element === null || element.scrollHeight - element.clientHeight < top)
32
+ return true;
33
+ element.scrollTop = top;
34
+ return false;
35
+ });
36
+ if (pending.length > 0 && performance.now() < until)
37
+ requestAnimationFrame(step);
38
+ };
39
+ requestAnimationFrame(step);
40
+ }
41
+ // Storage can be blocked (privacy settings); never worth breaking the page for.
42
+ export function takeSession(key) {
43
+ try {
44
+ const value = sessionStorage.getItem(key);
45
+ sessionStorage.removeItem(key);
46
+ return value;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ export function readSession(key) {
53
+ try {
54
+ return sessionStorage.getItem(key);
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ export function writeSession(key, value) {
61
+ try {
62
+ sessionStorage.setItem(key, value);
63
+ }
64
+ catch {
65
+ return;
66
+ }
67
+ }
68
+ function parseJson(text) {
69
+ try {
70
+ return JSON.parse(text);
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
@@ -1,7 +1,5 @@
1
1
  import { type QueryClient } from "@tanstack/react-query";
2
2
  import { z } from "zod/v4";
3
- /** How often a visible tab asks what changed: an agent's write shows within about this long. */
4
- export declare const POLL_MS = 3000;
5
3
  declare const changes: z.ZodObject<{
6
4
  cursor: z.ZodString;
7
5
  changedTables: z.ZodArray<z.ZodString>;
@@ -6,9 +6,9 @@
6
6
  // reconnect), later a pushed one (SSE) behind the same type.
7
7
  import { QueryObserver } from "@tanstack/react-query";
8
8
  import { z } from "zod/v4";
9
- import { CHANGES_PATH, EntityError, tableQueryKey } from "../entities.js";
10
- /** How often a visible tab asks what changed: an agent's write shows within about this long. */
11
- export const POLL_MS = 3_000;
9
+ import { EntityError, tableQueryKey } from "../entities.js";
10
+ import { CHANGES_PATH, QUERY_ROOT } from "./contract.js";
11
+ import { POLL_MS } from "./timing.js";
12
12
  const changes = z.object({ cursor: z.string(), changedTables: z.array(z.string()) });
13
13
  /**
14
14
  * Polls `_changes` as a TanStack query. One the server breaks on (or answers
@@ -20,7 +20,7 @@ export function pollChanges(queries, fetchChanges) {
20
20
  let cursor = null;
21
21
  let reported = false;
22
22
  const observer = new QueryObserver(queries, {
23
- queryKey: ["light", CHANGES_PATH],
23
+ queryKey: [QUERY_ROOT, CHANGES_PATH],
24
24
  queryFn: () => fetchChanges(cursor),
25
25
  // Signed out: nothing to watch until the tab is shown again.
26
26
  refetchInterval: (query) => (query.state.data === "signed-out" ? false : POLL_MS),
@@ -49,7 +49,7 @@ export function pollChanges(queries, fetchChanges) {
49
49
  });
50
50
  return () => {
51
51
  stop();
52
- queries.removeQueries({ queryKey: ["light", CHANGES_PATH], exact: true });
52
+ queries.removeQueries({ queryKey: [QUERY_ROOT, CHANGES_PATH], exact: true });
53
53
  };
54
54
  };
55
55
  }
@@ -0,0 +1,4 @@
1
+ /** `buildId` null off Vercel: a dev server has HMR, nothing is watched. */
2
+ export declare function ReloadOnNewBuild({ buildId }: {
3
+ buildId: string | null;
4
+ }): null;
@@ -0,0 +1,65 @@
1
+ "use client";
2
+ // The browser half of new-build.ts, mounted by the template's MiiPlatform.
3
+ import { useQueryClient } from "@tanstack/react-query";
4
+ import { useRouter } from "@tanstack/react-router";
5
+ import { useEffect, useState } from "react";
6
+ import { STORAGE_KEYS } from "./contract.js";
7
+ import { keepScroll, readSession, restoreScroll, writeSession } from "./keep-scroll.js";
8
+ import { fetchLive, watchBuild } from "./new-build.js";
9
+ const TYPING_IN = new Set(["INPUT", "TEXTAREA", "SELECT"]);
10
+ /** Through shadow roots: the platform's chat bubble is one. */
11
+ function focusedElement() {
12
+ let focused = document.activeElement;
13
+ while (focused !== null && focused.shadowRoot !== null && focused.shadowRoot.activeElement !== null)
14
+ focused = focused.shadowRoot.activeElement;
15
+ return focused;
16
+ }
17
+ function busy() {
18
+ const focused = focusedElement();
19
+ if (focused instanceof HTMLElement && (TYPING_IN.has(focused.tagName) || focused.isContentEditable))
20
+ return true;
21
+ return document.querySelector('[role="dialog"], dialog[open]') !== null;
22
+ }
23
+ /** `buildId` null off Vercel: a dev server has HMR, nothing is watched. */
24
+ export function ReloadOnNewBuild({ buildId }) {
25
+ // router.invalidate() re-runs the root loader, which would answer with the live build.
26
+ const [servedBy] = useState(buildId);
27
+ const queries = useQueryClient();
28
+ const router = useRouter();
29
+ useEffect(() => {
30
+ if (servedBy === null)
31
+ return;
32
+ restoreScroll();
33
+ return watchBuild({
34
+ buildId: servedBy,
35
+ queries,
36
+ fetchLive,
37
+ visible: () => document.visibilityState === "visible",
38
+ busy,
39
+ // The router also announces its own reloads and search-param changes.
40
+ onNavigate: (callback) => router.subscribe("onBeforeNavigate", ({ toLocation, pathChanged }) => {
41
+ if (pathChanged)
42
+ callback(toLocation.publicHref);
43
+ }),
44
+ onChunkMissing: (callback) => {
45
+ // Not prevented, Vite throws the error on to the page.
46
+ const onError = (event) => {
47
+ if (callback())
48
+ event.preventDefault();
49
+ };
50
+ window.addEventListener("vite:preloadError", onError);
51
+ return () => window.removeEventListener("vite:preloadError", onError);
52
+ },
53
+ reload: () => {
54
+ keepScroll();
55
+ window.location.reload();
56
+ },
57
+ go: (href) => window.location.assign(href),
58
+ chunkRetryBuildId: {
59
+ get: () => readSession(STORAGE_KEYS.chunkRetryBuildId),
60
+ set: (build) => writeSession(STORAGE_KEYS.chunkRetryBuildId, build)
61
+ }
62
+ });
63
+ }, [servedBy, queries, router]);
64
+ return null;
65
+ }
@@ -0,0 +1,21 @@
1
+ import { type QueryClient } from "@tanstack/react-query";
2
+ export type BuildEnv = {
3
+ buildId: string;
4
+ queries: QueryClient;
5
+ fetchLive(): Promise<string | null>;
6
+ visible(): boolean;
7
+ /** An input has focus, or a dialog is open. */
8
+ busy(): boolean;
9
+ /** Before a navigation to another page. */
10
+ onNavigate(callback: (href: string) => void): () => void;
11
+ /** vite:preloadError; the callback says whether it reloaded. */
12
+ onChunkMissing(callback: () => boolean): () => void;
13
+ reload(): void;
14
+ go(href: string): void;
15
+ chunkRetryBuildId: {
16
+ get(): string | null;
17
+ set(build: string): void;
18
+ };
19
+ };
20
+ export declare function watchBuild(env: BuildEnv): () => void;
21
+ export declare function fetchLive(): Promise<string | null>;
@@ -0,0 +1,63 @@
1
+ // An open page moves onto a newly published build at a moment nobody is mid-task.
2
+ import { QueryObserver } from "@tanstack/react-query";
3
+ import { z } from "zod/v4";
4
+ import { BUILD_PATH, QUERY_ROOT } from "./contract.js";
5
+ import { CHECK_MS, IDLE_MS } from "./timing.js";
6
+ const live = z.object({ buildId: z.string().nullable() });
7
+ export function watchBuild(env) {
8
+ let stale = false;
9
+ let quietCheck = null;
10
+ const stopQuietCheck = () => {
11
+ if (quietCheck !== null)
12
+ clearTimeout(quietCheck);
13
+ };
14
+ const reloadWhenQuiet = () => {
15
+ if (!env.visible())
16
+ return env.reload();
17
+ quietCheck = setTimeout(() => (!env.visible() || !env.busy() ? env.reload() : reloadWhenQuiet()), IDLE_MS);
18
+ };
19
+ const check = new QueryObserver(env.queries, {
20
+ queryKey: [QUERY_ROOT, BUILD_PATH],
21
+ queryFn: () => env.fetchLive(),
22
+ refetchInterval: CHECK_MS,
23
+ // A hidden tab is the best one to reload.
24
+ refetchIntervalInBackground: true,
25
+ refetchOnWindowFocus: "always",
26
+ refetchOnReconnect: "always",
27
+ retry: false,
28
+ notifyOnChangeProps: ["data"]
29
+ });
30
+ const stopCheck = check.subscribe((result) => {
31
+ if (stale || result.status !== "success" || result.data === env.buildId)
32
+ return;
33
+ stale = true;
34
+ stopCheck();
35
+ reloadWhenQuiet();
36
+ });
37
+ const stopNavigate = env.onNavigate((href) => {
38
+ if (!stale)
39
+ return void check.refetch();
40
+ stopQuietCheck();
41
+ env.go(href);
42
+ });
43
+ const stopChunks = env.onChunkMissing(() => {
44
+ if (env.chunkRetryBuildId.get() === env.buildId)
45
+ return false;
46
+ env.chunkRetryBuildId.set(env.buildId);
47
+ env.reload();
48
+ return true;
49
+ });
50
+ return () => {
51
+ stopQuietCheck();
52
+ stopCheck();
53
+ stopNavigate();
54
+ stopChunks();
55
+ env.queries.removeQueries({ queryKey: [QUERY_ROOT, BUILD_PATH], exact: true });
56
+ };
57
+ }
58
+ export async function fetchLive() {
59
+ const response = await fetch(BUILD_PATH, { cache: "no-store" });
60
+ if (!response.ok)
61
+ throw new Error(`GET ${BUILD_PATH} failed (${response.status}).`);
62
+ return live.parse(await response.json()).buildId;
63
+ }
@@ -17,14 +17,15 @@ import { jsx as _jsx } from "react/jsx-runtime";
17
17
  // This is only the live channel to the pane, and it leaves no trace.
18
18
  import { useEffect, useRef } from "react";
19
19
  import { z } from "zod/v4";
20
+ import { PANE_MESSAGES } from "./contract.js";
20
21
  import { PLAZA_FRAME_ORIGINS } from "./index.js";
21
22
  import { LiveData } from "./live-data-mount.js";
22
23
  const PLAZA_ORIGINS = PLAZA_FRAME_ORIGINS;
23
24
  /** The platform → app. Anything else the platform posts is not for this bridge. */
24
25
  const inbound = z.discriminatedUnion("type", [
25
- z.object({ type: z.literal("mii-preview:hello") }),
26
- z.object({ type: z.literal("mii-preview:select"), on: z.boolean() }),
27
- z.object({ type: z.literal("mii-preview:locate"), id: z.string(), selector: z.string() })
26
+ z.object({ type: z.literal(PANE_MESSAGES.hello) }),
27
+ z.object({ type: z.literal(PANE_MESSAGES.select), on: z.boolean() }),
28
+ z.object({ type: z.literal(PANE_MESSAGES.locate), id: z.string(), selector: z.string() })
28
29
  ]);
29
30
  /**
30
31
  * Route changes without a router dependency: popstate covers back/forward,
@@ -76,12 +77,12 @@ export function MiiPaneBridge() {
76
77
  // these too, but Sentry is somewhere you go afterwards — an agent watching
77
78
  // its own change land needs to know now.
78
79
  const onError = (event) => send({
79
- type: "mii-preview:error",
80
+ type: PANE_MESSAGES.error,
80
81
  message: event.message,
81
82
  source: `${event.filename}:${event.lineno}`
82
83
  });
83
84
  const onRejection = (event) => send({
84
- type: "mii-preview:error",
85
+ type: PANE_MESSAGES.error,
85
86
  message: String(event.reason),
86
87
  source: "unhandled rejection"
87
88
  });
@@ -118,7 +119,7 @@ export function MiiPaneBridge() {
118
119
  // arrives here under the name it already has.
119
120
  const label = target.getAttribute("data-el");
120
121
  send({
121
- type: "mii-preview:picked",
122
+ type: PANE_MESSAGES.picked,
122
123
  label: label === null ? target.textContent.trim().slice(0, 80) : label,
123
124
  selector: selectorFor(target),
124
125
  tag: target.tagName.toLowerCase(),
@@ -133,11 +134,11 @@ export function MiiPaneBridge() {
133
134
  if (!read.success)
134
135
  return;
135
136
  const message = read.data;
136
- if (message.type === "mii-preview:hello") {
137
- send({ type: "mii-preview:ready", ...describe() });
137
+ if (message.type === PANE_MESSAGES.hello) {
138
+ send({ type: PANE_MESSAGES.ready, ...describe() });
138
139
  return;
139
140
  }
140
- if (message.type === "mii-preview:select") {
141
+ if (message.type === PANE_MESSAGES.select) {
141
142
  selecting = message.on;
142
143
  document.body.style.cursor = selecting ? "crosshair" : "";
143
144
  if (!selecting)
@@ -150,7 +151,7 @@ export function MiiPaneBridge() {
150
151
  // different size than the one being replayed.
151
152
  const found = document.querySelector(message.selector);
152
153
  send({
153
- type: "mii-preview:located",
154
+ type: PANE_MESSAGES.located,
154
155
  id: message.id,
155
156
  rect: found === null ? null : rectOf(found)
156
157
  });
@@ -160,7 +161,7 @@ export function MiiPaneBridge() {
160
161
  const plaza = plazaRef.current;
161
162
  if (plaza === null)
162
163
  return;
163
- window.parent.postMessage({ type: "mii-preview:route", ...describe() }, plaza);
164
+ window.parent.postMessage({ type: PANE_MESSAGES.route, ...describe() }, plaza);
164
165
  });
165
166
  document.body.appendChild(outline);
166
167
  window.addEventListener("error", onError);
@@ -0,0 +1,12 @@
1
+ /** Live data: an agent's change shows on an open page within about this long. */
2
+ export declare const POLL_MS = 3000;
3
+ /** New build: how often a tab asks which build is live; browsers throttle hidden tabs' timers to about this anyway. */
4
+ export declare const CHECK_MS = 60000;
5
+ /** New build: how long nobody may be typing or have a dialog open before a visible tab reloads. */
6
+ export declare const IDLE_MS = 5000;
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
+ export declare const SCROLL_RESTORE_MS = 5000;
9
+ /** A contract read that failed: how long before asking again. */
10
+ export declare const INVOCATION_RETRY_MS = 1500;
11
+ /** How long a page remembers a contract invocation it started, across reloads. */
12
+ export declare const INVOCATION_REMEMBER_MS: number;
@@ -0,0 +1,13 @@
1
+ // How often and how long the SDK waits in the browser: the knobs, in one place.
2
+ /** Live data: an agent's change shows on an open page within about this long. */
3
+ export const POLL_MS = 3_000;
4
+ /** New build: how often a tab asks which build is live; browsers throttle hidden tabs' timers to about this anyway. */
5
+ export const CHECK_MS = 60_000;
6
+ /** New build: how long nobody may be typing or have a dialog open before a visible tab reloads. */
7
+ export const IDLE_MS = 5_000;
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
+ export const SCROLL_RESTORE_MS = 5_000;
10
+ /** A contract read that failed: how long before asking again. */
11
+ export const INVOCATION_RETRY_MS = 1500;
12
+ /** How long a page remembers a contract invocation it started, across reloads. */
13
+ export const INVOCATION_REMEMBER_MS = 24 * 60 * 60 * 1000;
@@ -1,2 +1,3 @@
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
+ export { ReloadOnNewBuild } from "./platform/new-build-mount.js";
package/dist/platform.js CHANGED
@@ -1,5 +1,6 @@
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
- // and the pane bridge.
3
+ // the pane bridge, and moving an open page onto a new build.
4
4
  export { FRAME_ANCESTORS, isAgentBrowser, mayCallServerFn, miiAnalyticsPolicy, miiErrorPolicy, PLAZA_FRAME_ORIGINS } from "./platform/index.js";
5
5
  export { MiiPaneBridge } from "./platform/pane-bridge.js";
6
+ export { ReloadOnNewBuild } from "./platform/new-build-mount.js";
@@ -8,7 +8,8 @@ import { isDeepStrictEqual } from "node:util";
8
8
  import { createSchemaFactory } from "drizzle-zod";
9
9
  import { isSameOrigin } from "../chat/server.js";
10
10
  import { z } from "zod/v4";
11
- import { CHANGES_PATH, ENTITIES_PATH, LIST_LIMIT } from "../entities.js";
11
+ import { LIST_LIMIT } from "../entities.js";
12
+ import { CHANGES_PATH, ENTITIES_PATH } from "../platform/contract.js";
12
13
  import { PLATFORM_COLUMNS, fieldSource, isEntity, lightChanges } from "../schema.js";
13
14
  import { env } from "./env.js";
14
15
  /** Drizzle's text column types: what a search reads. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trylight/app",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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,6 +12,7 @@
12
12
  "url": "https://github.com/shubh9/mii.git",
13
13
  "directory": "packages/app-sdk"
14
14
  },
15
+ "packageManager": "pnpm@11.0.9",
15
16
  "files": [
16
17
  "dist",
17
18
  "bin"
@@ -41,6 +42,15 @@
41
42
  "default": "./dist/vite.js"
42
43
  }
43
44
  },
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
+ },
44
54
  "peerDependencies": {
45
55
  "@tanstack/react-query": ">=5.69",
46
56
  "@tanstack/react-router": ">=1.170",
@@ -74,13 +84,5 @@
74
84
  "typescript-eslint": "^8.70.1",
75
85
  "vite": "^8.2.0",
76
86
  "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"
85
87
  }
86
- }
88
+ }
@@ -1,2 +0,0 @@
1
- /** Where an app mounts its invoke routes, and where `invoke()` calls them. */
2
- export declare const INVOKE_BASE_PATH = "/api/mii/invoke";
@@ -1,2 +0,0 @@
1
- /** Where an app mounts its invoke routes, and where `invoke()` calls them. */
2
- export const INVOKE_BASE_PATH = "/api/mii/invoke";