@shubh90/app-runtime 0.3.0 → 0.4.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/README.md CHANGED
@@ -29,11 +29,20 @@ and autosave never fights a kit-dirtied tree. Apps pin a version; a breaking
29
29
  Plaza contract change is a semver major with a deprecation window, not a flag
30
30
  day.
31
31
 
32
- ## Publishing (GitHub Packages)
32
+ ## Publishing (npmjs, public)
33
33
 
34
- Built and published from the monorepo on a `app-runtime-v*` tag by
35
- `.github/workflows/publish-app-runtime.yml`. The registry is
36
- `npm.pkg.github.com`, scope `@mii`.
34
+ Every released version (0.1.0 →) lives on the PUBLIC npm registry — that is
35
+ where app installs resolve, with no auth to provision in any build
36
+ environment, which is the point. Publish from a machine logged in as
37
+ `shubh90`:
38
+
39
+ ```bash
40
+ # from packages/app-runtime, after npm run check && npm run build:
41
+ npm publish --registry=https://registry.npmjs.org
42
+ ```
43
+
44
+ The `app-runtime-v*` tag workflow publishes a mirror copy to GitHub Packages;
45
+ nothing installs from there today, so treat npmjs as the source of truth.
37
46
 
38
47
  ```bash
39
48
  # maintainers, from packages/app-runtime:
@@ -1,5 +1,6 @@
1
- /** Plaza origins allowed to frame an org app. Keep in step with the pane. */
2
- export declare const PLAZA_FRAME_ORIGINS: readonly ["https://plaza.miis.run", "https://miiplaza.vercel.app", "http://localhost:3000", "http://localhost:3111"];
1
+ import { PLAZA_FRAME_ORIGINS } from "../platform/index.js";
2
+ /** Re-exported for existing importers; the source of truth is ./platform. */
3
+ export { PLAZA_FRAME_ORIGINS };
3
4
  type Header = {
4
5
  key: string;
5
6
  value: string;
@@ -14,4 +15,3 @@ type NextConfigLike = {
14
15
  [k: string]: unknown;
15
16
  };
16
17
  export declare function withMiiPlatform(config?: NextConfigLike): NextConfigLike;
17
- export {};
@@ -10,23 +10,10 @@
10
10
  // Newmark SF set `frame-ancestors 'none'` + `X-Frame-Options: DENY` in its own
11
11
  // config and white-screened its pane. This makes that impossible: whatever the
12
12
  // app sets, the resulting headers frame-allow Plaza and carry no X-Frame-Options.
13
- /** Plaza origins allowed to frame an org app. Keep in step with the pane. */
14
- export const PLAZA_FRAME_ORIGINS = [
15
- "https://plaza.miis.run",
16
- "https://miiplaza.vercel.app",
17
- "http://localhost:3000",
18
- "http://localhost:3111"
19
- ];
20
- const FRAME_ANCESTORS = `frame-ancestors 'self' ${PLAZA_FRAME_ORIGINS.join(" ")}`;
21
- /** Force `frame-ancestors` to allow Plaza in a Content-Security-Policy value. */
22
- function forceFrameAncestors(csp) {
23
- const directives = csp
24
- .split(";")
25
- .map((d) => d.trim())
26
- .filter((d) => d !== "" && !d.toLowerCase().startsWith("frame-ancestors"));
27
- directives.push(FRAME_ANCESTORS);
28
- return directives.join("; ");
29
- }
13
+ import { FRAME_ANCESTORS, forceFrameAncestors as forceCsp, PLAZA_FRAME_ORIGINS } from "../platform/index.js";
14
+ /** Re-exported for existing importers; the source of truth is ./platform. */
15
+ export { PLAZA_FRAME_ORIGINS };
16
+ const forceFrameAncestors = forceCsp;
30
17
  /** Rewrite one route's headers: fix any CSP's frame-ancestors, drop XFO. */
31
18
  function enforce(headers) {
32
19
  let sawCsp = false;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Plaza origins allowed to frame an org app AND to drive its preview bridge.
3
+ * One list on purpose: who may frame the app and who may talk to it must never
4
+ * drift apart. localhost is here because Plaza is developed locally against
5
+ * real deployed org apps.
6
+ */
7
+ export declare const PLAZA_FRAME_ORIGINS: readonly ["https://plaza.miis.run", "https://miiplaza.vercel.app", "http://localhost:3000", "http://localhost:3111"];
8
+ export declare const FRAME_ANCESTORS: string;
9
+ /** Force a Content-Security-Policy value's frame-ancestors to allow Plaza. */
10
+ export declare function forceFrameAncestors(csp: string): string;
11
+ /**
12
+ * Enforce the framing contract on one response's headers, in place: any CSP
13
+ * keeps its own directives but frame-allows Plaza, X-Frame-Options is dropped
14
+ * (it cannot allowlist; CSP governs), and a response with no CSP gains one.
15
+ * Wire it wherever the app touches every response once — a Start server
16
+ * middleware, a Node server hook — and the Newmark white-pane class of bug
17
+ * has no mechanism left.
18
+ */
19
+ export declare function applyMiiFraming(headers: Headers): void;
20
+ export type PlatformEnv = {
21
+ /** Vercel's environment name; undefined locally. */
22
+ readonly vercelEnv?: string;
23
+ /** MII_ORG_ID / VITE_MII_ORG_ID — the tag that makes telemetry attributable. */
24
+ readonly orgId?: string;
25
+ };
26
+ export type MiiErrorPolicy = {
27
+ readonly enabled: boolean;
28
+ readonly environment: string;
29
+ readonly tracesSampleRate: 0;
30
+ readonly sendDefaultPii: false;
31
+ readonly initialScope: {
32
+ readonly tags: {
33
+ readonly org_id: string | undefined;
34
+ };
35
+ };
36
+ };
37
+ /**
38
+ * The Sentry init options an org app must not diverge from, for any Sentry
39
+ * SDK (`Sentry.init({ dsn, ...miiErrorPolicy(...) })`). The Sentry project is
40
+ * shared across every org app, so an untagged event is un-attributable: the
41
+ * policy stays OFF rather than emit one. Errors are the only quota the
42
+ * platform pays for — tracing stays off. `force` mirrors SENTRY_FORCE_ENABLE
43
+ * for testing locally.
44
+ */
45
+ export declare function miiErrorPolicy(env: PlatformEnv & {
46
+ readonly dsn?: string;
47
+ readonly force?: boolean;
48
+ }): MiiErrorPolicy;
49
+ export type MiiAnalyticsPolicy = {
50
+ readonly enabled: boolean;
51
+ /** posthog-js init options: `posthog.init("in-house", options)`. */
52
+ readonly options: {
53
+ readonly api_host: string;
54
+ /** posthog-js "defaults era" — a date literal its ConfigDefaults union accepts. */
55
+ readonly defaults: "2026-06-25";
56
+ readonly person_profiles: "identified_only";
57
+ readonly advanced_disable_flags: true;
58
+ readonly disable_external_dependency_loading: true;
59
+ readonly disable_surveys: true;
60
+ readonly disable_session_recording: true;
61
+ readonly capture_exceptions: false;
62
+ };
63
+ };
64
+ /**
65
+ * Analytics is in-house: posthog-js is purely the capture SDK, events go to
66
+ * the mii ingest endpoint and no analytics vendor is involved. Off in local
67
+ * dev unless forced, so clicking around locally doesn't pollute product data.
68
+ * Cloud-only SDK features stay off so the client never phones home.
69
+ */
70
+ export declare function miiAnalyticsPolicy(env: PlatformEnv & {
71
+ readonly ingestHost: string;
72
+ readonly force?: boolean;
73
+ }): MiiAnalyticsPolicy;
74
+ /**
75
+ * Whether this browser is automation (the org's own agents, E2E runs), which
76
+ * analytics must keep separable from real users (`is_agent=true`).
77
+ * `?mii_agent=1` is the manual override and persists via localStorage;
78
+ * Playwright-family browsers set `navigator.webdriver`.
79
+ */
80
+ export declare function isAgentBrowser(win: Window): boolean;
@@ -0,0 +1,101 @@
1
+ // The platform policies every mii org app must agree on, framework-free and
2
+ // dependency-free. The framing list, the error-reporting rules and the
3
+ // analytics rules are contracts: an app that drifts on any of them breaks its
4
+ // deal with Plaza (a pane it cannot be shown in, errors nobody can attribute,
5
+ // events that pollute product data). Frameworks come and go around this file;
6
+ // nothing in it may import one.
7
+ /**
8
+ * Plaza origins allowed to frame an org app AND to drive its preview bridge.
9
+ * One list on purpose: who may frame the app and who may talk to it must never
10
+ * drift apart. localhost is here because Plaza is developed locally against
11
+ * real deployed org apps.
12
+ */
13
+ export const PLAZA_FRAME_ORIGINS = [
14
+ "https://plaza.miis.run",
15
+ "https://miiplaza.vercel.app",
16
+ "http://localhost:3000",
17
+ "http://localhost:3111"
18
+ ];
19
+ export const FRAME_ANCESTORS = `frame-ancestors 'self' ${PLAZA_FRAME_ORIGINS.join(" ")}`;
20
+ /** Force a Content-Security-Policy value's frame-ancestors to allow Plaza. */
21
+ export function forceFrameAncestors(csp) {
22
+ const directives = csp
23
+ .split(";")
24
+ .map((d) => d.trim())
25
+ .filter((d) => d !== "" && !d.toLowerCase().startsWith("frame-ancestors"));
26
+ directives.push(FRAME_ANCESTORS);
27
+ return directives.join("; ");
28
+ }
29
+ /**
30
+ * Enforce the framing contract on one response's headers, in place: any CSP
31
+ * keeps its own directives but frame-allows Plaza, X-Frame-Options is dropped
32
+ * (it cannot allowlist; CSP governs), and a response with no CSP gains one.
33
+ * Wire it wherever the app touches every response once — a Start server
34
+ * middleware, a Node server hook — and the Newmark white-pane class of bug
35
+ * has no mechanism left.
36
+ */
37
+ export function applyMiiFraming(headers) {
38
+ headers.delete("x-frame-options");
39
+ const csp = headers.get("content-security-policy");
40
+ headers.set("content-security-policy", csp === null ? FRAME_ANCESTORS : forceFrameAncestors(csp));
41
+ }
42
+ const isDeployed = (env) => env.vercelEnv === "production" || env.vercelEnv === "preview";
43
+ /**
44
+ * The Sentry init options an org app must not diverge from, for any Sentry
45
+ * SDK (`Sentry.init({ dsn, ...miiErrorPolicy(...) })`). The Sentry project is
46
+ * shared across every org app, so an untagged event is un-attributable: the
47
+ * policy stays OFF rather than emit one. Errors are the only quota the
48
+ * platform pays for — tracing stays off. `force` mirrors SENTRY_FORCE_ENABLE
49
+ * for testing locally.
50
+ */
51
+ export function miiErrorPolicy(env) {
52
+ return {
53
+ enabled: Boolean(env.dsn) &&
54
+ Boolean(env.orgId) &&
55
+ (isDeployed(env) || env.force === true),
56
+ environment: env.vercelEnv ?? "development",
57
+ tracesSampleRate: 0,
58
+ sendDefaultPii: false,
59
+ initialScope: { tags: { org_id: env.orgId } }
60
+ };
61
+ }
62
+ /**
63
+ * Analytics is in-house: posthog-js is purely the capture SDK, events go to
64
+ * the mii ingest endpoint and no analytics vendor is involved. Off in local
65
+ * dev unless forced, so clicking around locally doesn't pollute product data.
66
+ * Cloud-only SDK features stay off so the client never phones home.
67
+ */
68
+ export function miiAnalyticsPolicy(env) {
69
+ return {
70
+ enabled: isDeployed(env) || env.force === true,
71
+ options: {
72
+ api_host: env.ingestHost,
73
+ defaults: "2026-06-25",
74
+ person_profiles: "identified_only",
75
+ advanced_disable_flags: true,
76
+ disable_external_dependency_loading: true,
77
+ disable_surveys: true,
78
+ disable_session_recording: true,
79
+ capture_exceptions: false
80
+ }
81
+ };
82
+ }
83
+ /**
84
+ * Whether this browser is automation (the org's own agents, E2E runs), which
85
+ * analytics must keep separable from real users (`is_agent=true`).
86
+ * `?mii_agent=1` is the manual override and persists via localStorage;
87
+ * Playwright-family browsers set `navigator.webdriver`.
88
+ */
89
+ export function isAgentBrowser(win) {
90
+ try {
91
+ if (new URLSearchParams(win.location.search).has("mii_agent")) {
92
+ win.localStorage.setItem("mii_agent", "1");
93
+ }
94
+ return (win.navigator.webdriver === true ||
95
+ win.localStorage.getItem("mii_agent") === "1");
96
+ }
97
+ catch {
98
+ // localStorage can be unavailable (privacy modes); never break the app.
99
+ return win.navigator.webdriver === true;
100
+ }
101
+ }
@@ -0,0 +1 @@
1
+ export { MiiPaneBridge } from "./pane-bridge.js";
@@ -0,0 +1,4 @@
1
+ // React pieces of the platform contract. Framework-free React: no Next, no
2
+ // TanStack imports — route changes are observed from the History API itself,
3
+ // so the same component is correct under any router.
4
+ export { MiiPaneBridge } from "./pane-bridge.js";
@@ -0,0 +1 @@
1
+ export declare function MiiPaneBridge(): null;
@@ -0,0 +1,206 @@
1
+ "use client";
2
+ // How an org app talks to Plaza when Plaza is showing it in a pane.
3
+ //
4
+ // Plaza embeds the deployment in an iframe. That iframe is a different origin,
5
+ // so the browser will not let Plaza read the route inside it, catch its
6
+ // errors, or find an element in it — a preview with none of those is a picture
7
+ // of an app rather than something you can work on. This posts the missing
8
+ // facts outward and answers the two questions Plaza can ask.
9
+ //
10
+ // Plaza speaks first. Until an allowed origin says hello this app posts
11
+ // nothing at all, so being framed by someone else leaks nothing: no route, no
12
+ // errors, no DOM. That handshake is also why the parent origin is never
13
+ // guessed from `document.referrer`, which an iframe can suppress.
14
+ //
15
+ // Deliberately no analytics — the ingest pipe already records what people do.
16
+ // This is only the live channel to the pane, and it leaves no trace.
17
+ import { useEffect, useRef } from "react";
18
+ import { PLAZA_FRAME_ORIGINS } from "../platform/index.js";
19
+ const PLAZA_ORIGINS = PLAZA_FRAME_ORIGINS;
20
+ /**
21
+ * Route changes without a router dependency: popstate covers back/forward,
22
+ * and pushState/replaceState are wrapped to announce themselves, which every
23
+ * SPA router ultimately calls. The wrap is installed once per mount and
24
+ * restored on unmount.
25
+ */
26
+ function onRouteChange(callback) {
27
+ const announce = () => queueMicrotask(callback);
28
+ const wrap = (method) => {
29
+ const original = window.history[method].bind(window.history);
30
+ const wrapped = (...args) => {
31
+ original(...args);
32
+ announce();
33
+ };
34
+ window.history[method] = wrapped;
35
+ return () => {
36
+ window.history[method] = original;
37
+ };
38
+ };
39
+ const restorePush = wrap("pushState");
40
+ const restoreReplace = wrap("replaceState");
41
+ window.addEventListener("popstate", announce);
42
+ return () => {
43
+ restorePush();
44
+ restoreReplace();
45
+ window.removeEventListener("popstate", announce);
46
+ };
47
+ }
48
+ export function MiiPaneBridge() {
49
+ // Set by the handshake, read by the route announcements. A ref rather than
50
+ // state: nothing renders from it.
51
+ const plazaRef = useRef(null);
52
+ useEffect(() => {
53
+ // `window.parent === window` when this app is the whole tab, which is the
54
+ // ordinary case — someone just using the app.
55
+ if (window.parent === window)
56
+ return;
57
+ const describe = () => ({
58
+ path: window.location.pathname + window.location.search,
59
+ title: document.title
60
+ });
61
+ const send = (message) => {
62
+ const plaza = plazaRef.current;
63
+ if (plaza !== null)
64
+ window.parent.postMessage(message, plaza);
65
+ };
66
+ // Runtime failures the pane would otherwise never hear about. Sentry gets
67
+ // these too, but Sentry is somewhere you go afterwards — an agent watching
68
+ // its own change land needs to know now.
69
+ const onError = (event) => send({
70
+ type: "mii-preview:error",
71
+ message: event.message,
72
+ source: `${event.filename}:${event.lineno}`
73
+ });
74
+ const onRejection = (event) => send({
75
+ type: "mii-preview:error",
76
+ message: String(event.reason),
77
+ source: "unhandled rejection"
78
+ });
79
+ // Select mode. Hovering paints one outline element — reused, never
80
+ // stacked — and clicking reports what was picked instead of activating
81
+ // it: you are pointing at a button to talk about it, not pressing it.
82
+ let selecting = false;
83
+ const outline = document.createElement("div");
84
+ outline.setAttribute("aria-hidden", "true");
85
+ // Injected imperatively and deliberately outside the design system: this
86
+ // paints over the app, so it must not inherit the app's own theming.
87
+ outline.style.cssText =
88
+ "position:fixed;pointer-events:none;z-index:2147483647;display:none;" +
89
+ "border:2px solid #4f7cff;border-radius:3px;background:rgba(79,124,255,0.12)";
90
+ const onMove = (event) => {
91
+ if (!selecting)
92
+ return;
93
+ const rect = event.target.getBoundingClientRect();
94
+ outline.style.display = "block";
95
+ outline.style.top = `${rect.top}px`;
96
+ outline.style.left = `${rect.left}px`;
97
+ outline.style.width = `${rect.width}px`;
98
+ outline.style.height = `${rect.height}px`;
99
+ };
100
+ const onClick = (event) => {
101
+ if (!selecting)
102
+ return;
103
+ event.preventDefault();
104
+ event.stopPropagation();
105
+ const target = event.target;
106
+ send({
107
+ type: "mii-preview:picked",
108
+ // `data-el` is the codebase's label convention — the analytics ingest
109
+ // reads the same attribute, so an element already named for a chart
110
+ // arrives here under the name it already has.
111
+ label: target.getAttribute("data-el") ??
112
+ (target.textContent ?? "").trim().slice(0, 80),
113
+ selector: selectorFor(target),
114
+ tag: target.tagName.toLowerCase(),
115
+ ...describe()
116
+ });
117
+ };
118
+ const onMessage = (event) => {
119
+ if (!PLAZA_ORIGINS.includes(event.origin))
120
+ return;
121
+ plazaRef.current = event.origin;
122
+ const message = event.data;
123
+ if (message.type === "mii-preview:hello") {
124
+ send({ type: "mii-preview:ready", ...describe() });
125
+ return;
126
+ }
127
+ if (message.type === "mii-preview:select") {
128
+ selecting = message.on;
129
+ document.body.style.cursor = selecting ? "crosshair" : "";
130
+ if (!selecting)
131
+ outline.style.display = "none";
132
+ return;
133
+ }
134
+ if (message.type === "mii-preview:locate") {
135
+ // Where is this element, right now, in this window? Plaza uses the
136
+ // answer to put a cursor on it. Asked by element rather than by
137
+ // coordinates so the answer stays right when this window is a
138
+ // different size than the one being replayed.
139
+ const found = document.querySelector(message.selector);
140
+ send({
141
+ type: "mii-preview:located",
142
+ id: message.id,
143
+ rect: found === null ? null : rectOf(found)
144
+ });
145
+ }
146
+ };
147
+ // A navigation reports itself; the listener set above stays put.
148
+ const stopRouteWatch = onRouteChange(() => {
149
+ const plaza = plazaRef.current;
150
+ if (plaza === null)
151
+ return;
152
+ window.parent.postMessage({ type: "mii-preview:route", ...describe() }, plaza);
153
+ });
154
+ document.body.appendChild(outline);
155
+ window.addEventListener("error", onError);
156
+ window.addEventListener("unhandledrejection", onRejection);
157
+ window.addEventListener("message", onMessage);
158
+ document.addEventListener("mousemove", onMove, true);
159
+ document.addEventListener("click", onClick, true);
160
+ return () => {
161
+ stopRouteWatch();
162
+ window.removeEventListener("error", onError);
163
+ window.removeEventListener("unhandledrejection", onRejection);
164
+ window.removeEventListener("message", onMessage);
165
+ document.removeEventListener("mousemove", onMove, true);
166
+ document.removeEventListener("click", onClick, true);
167
+ document.body.style.cursor = "";
168
+ outline.remove();
169
+ };
170
+ }, []);
171
+ return null;
172
+ }
173
+ function rectOf(element) {
174
+ const rect = element.getBoundingClientRect();
175
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
176
+ }
177
+ /**
178
+ * A selector that finds this element again in another window. Prefers the
179
+ * names that survive a re-render — an id, the `data-el` label — and only
180
+ * counts siblings when an element carries no name of its own.
181
+ */
182
+ function selectorFor(element) {
183
+ const parts = [];
184
+ let node = element;
185
+ while (node !== null && node !== document.body) {
186
+ if (node.id !== "") {
187
+ parts.unshift(`#${CSS.escape(node.id)}`);
188
+ break;
189
+ }
190
+ const label = node.getAttribute("data-el");
191
+ if (label !== null) {
192
+ parts.unshift(`[data-el="${CSS.escape(label)}"]`);
193
+ break;
194
+ }
195
+ const parent = node.parentElement;
196
+ const tag = node.tagName.toLowerCase();
197
+ if (parent === null) {
198
+ parts.unshift(tag);
199
+ break;
200
+ }
201
+ const twins = [...parent.children].filter((child) => child.tagName === node?.tagName);
202
+ parts.unshift(twins.length === 1 ? tag : `${tag}:nth-of-type(${twins.indexOf(node) + 1})`);
203
+ node = parent;
204
+ }
205
+ return parts.join(" > ");
206
+ }
@@ -0,0 +1,10 @@
1
+ export type BuildMarkerOptions = {
2
+ /** Defaults to VERCEL_DEPLOYMENT_ID / VERCEL_GIT_COMMIT_SHA, read per request. */
3
+ readonly deploymentId?: () => string | undefined;
4
+ readonly commitSha?: () => string | undefined;
5
+ };
6
+ /** GET + OPTIONS for /api/mii-build; mount both from one factory. */
7
+ export declare function createBuildMarkerHandler(options?: BuildMarkerOptions): {
8
+ GET(request: Request): Response;
9
+ OPTIONS(request: Request): Response;
10
+ };
@@ -0,0 +1,51 @@
1
+ // Which build of this app is currently serving.
2
+ //
3
+ // Plaza's App pane is an iframe of the deployment. When an agent ships, the
4
+ // URL does not move, so nothing tells the pane the page underneath it is now
5
+ // stale — the pane polls this and reloads the frame when the answer changes.
6
+ // Plaza's server asks the same question for agents (deployedCommitOf), which
7
+ // is why this stays a route on the app: "the domain answers with this commit"
8
+ // is a stronger claim than any deploy provider's READY, and it needs no
9
+ // provider token to work.
10
+ //
11
+ // Deliberately touches nothing: no database, no imports beyond the origin
12
+ // list. It is polled every few seconds by every open pane, so it has to be
13
+ // the cheapest route in the app — the health check next door opens a Postgres
14
+ // connection and would wake the database on a timer forever.
15
+ import { PLAZA_FRAME_ORIGINS } from "../platform/index.js";
16
+ function corsHeaders(origin) {
17
+ // Echo the caller's origin only when it is a Plaza we know. `*` would let
18
+ // any page on the internet read this; harmless today, but this is the
19
+ // pattern the next endpoint copies, and the next one may not be harmless.
20
+ return origin !== null && PLAZA_FRAME_ORIGINS.includes(origin)
21
+ ? {
22
+ "Access-Control-Allow-Origin": origin,
23
+ Vary: "Origin",
24
+ "Cache-Control": "no-store"
25
+ }
26
+ : { "Cache-Control": "no-store" };
27
+ }
28
+ /** GET + OPTIONS for /api/mii-build; mount both from one factory. */
29
+ export function createBuildMarkerHandler(options = {}) {
30
+ const deploymentId = options.deploymentId ?? (() => process.env.VERCEL_DEPLOYMENT_ID);
31
+ const commitSha = options.commitSha ?? (() => process.env.VERCEL_GIT_COMMIT_SHA);
32
+ return {
33
+ GET(request) {
34
+ // The deployment id changes on every deploy, including a redeploy of the
35
+ // same commit — which is exactly the question being asked. The commit
36
+ // rides along because it is the half a human can act on.
37
+ const deployment = deploymentId() ?? null;
38
+ const commit = commitSha() ?? null;
39
+ return Response.json({ build: deployment ?? commit, commit }, { headers: corsHeaders(request.headers.get("origin")) });
40
+ },
41
+ OPTIONS(request) {
42
+ return new Response(null, {
43
+ status: 204,
44
+ headers: {
45
+ ...corsHeaders(request.headers.get("origin")),
46
+ "Access-Control-Allow-Methods": "GET, OPTIONS"
47
+ }
48
+ });
49
+ }
50
+ };
51
+ }
@@ -0,0 +1,9 @@
1
+ export declare const DB_RETRY_ATTEMPTS = 4;
2
+ export declare function isTransientConnectionError(error: unknown): boolean;
3
+ export type DbRetryOptions = {
4
+ readonly attempts?: number;
5
+ readonly baseDelayMs?: number;
6
+ /** Injected by tests so they do not spend real seconds sleeping. */
7
+ readonly sleep?: (ms: number) => Promise<void>;
8
+ };
9
+ export declare function withDbRetry<T>(operation: () => Promise<T>, options?: DbRetryOptions): Promise<T>;
@@ -0,0 +1,54 @@
1
+ // Ported from the Next template's src/lib/db-retry.ts, where it earned its
2
+ // shape in production (transient EAI_AGAIN across several orgs, 2026-08-22):
3
+ // postgres.js connects lazily, so a resolver blip surfaces on the first query
4
+ // and nothing underneath tries again. Retry the connect, and only the connect
5
+ // — a wrong password or a missing table must still fail on attempt one.
6
+ const TRANSIENT_CODES = new Set([
7
+ "EAI_AGAIN",
8
+ "ENOTFOUND",
9
+ "ETIMEDOUT",
10
+ "ECONNRESET",
11
+ "ECONNREFUSED",
12
+ "ENETUNREACH",
13
+ "EHOSTUNREACH",
14
+ "CONNECT_TIMEOUT"
15
+ ]);
16
+ export const DB_RETRY_ATTEMPTS = 4;
17
+ const BASE_DELAY_MS = 250;
18
+ function errorCode(error) {
19
+ let current = error;
20
+ for (let depth = 0; current !== null && current !== undefined && depth < 5; depth += 1) {
21
+ const code = current.code;
22
+ if (typeof code === "string")
23
+ return code;
24
+ current = current.cause;
25
+ }
26
+ return undefined;
27
+ }
28
+ export function isTransientConnectionError(error) {
29
+ const code = errorCode(error);
30
+ return code !== undefined && TRANSIENT_CODES.has(code);
31
+ }
32
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
33
+ export async function withDbRetry(operation, options = {}) {
34
+ const attempts = options.attempts ?? DB_RETRY_ATTEMPTS;
35
+ const baseDelayMs = options.baseDelayMs ?? BASE_DELAY_MS;
36
+ const sleep = options.sleep ?? wait;
37
+ let lastError;
38
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
39
+ try {
40
+ return await operation();
41
+ }
42
+ catch (error) {
43
+ if (!isTransientConnectionError(error))
44
+ throw error;
45
+ lastError = error;
46
+ if (attempt < attempts)
47
+ await sleep(baseDelayMs * 2 ** (attempt - 1));
48
+ }
49
+ }
50
+ throw new Error(`Could not reach the database after ${attempts} attempts ` +
51
+ `(${errorCode(lastError) ?? "unknown"}). The host did not resolve or ` +
52
+ `accept a connection. This is a transient network fault, not a ` +
53
+ `credential, schema or permission problem — run the command again.`, { cause: lastError });
54
+ }
@@ -0,0 +1,21 @@
1
+ import { type DbRetryOptions } from "./db-retry.js";
2
+ export { withDbRetry, isTransientConnectionError, DB_RETRY_ATTEMPTS } from "./db-retry.js";
3
+ export type { DbRetryOptions } from "./db-retry.js";
4
+ export { createBuildMarkerHandler, type BuildMarkerOptions } from "./build-marker.js";
5
+ export { createSentryTunnelHandler, type SentryTunnelOptions } from "./sentry-tunnel.js";
6
+ export type HealthHandlerOptions = {
7
+ /** Defaults to process.env.DATABASE_URL, read per request. */
8
+ readonly databaseUrl?: () => string | undefined;
9
+ /** Defaults to process.env.MII_ORG_ID. */
10
+ readonly orgId?: () => string | undefined;
11
+ /** Test hook: shrink attempts/delays so failure paths don't spend seconds. */
12
+ readonly retry?: DbRetryOptions;
13
+ };
14
+ /**
15
+ * GET /api/health — the readiness gate. The sandbox refuses to report an
16
+ * instance as up, and Plaza refuses to wake one, until this answers ok. It
17
+ * proves the one dependency that page loads actually need: the database
18
+ * answers a query. A transient DNS fault must not read as an unhealthy app,
19
+ * hence the connect retry.
20
+ */
21
+ export declare function createHealthHandler(options?: HealthHandlerOptions): () => Promise<Response>;
@@ -0,0 +1,45 @@
1
+ // Web-standard server handlers (Request → Response) for the routes the
2
+ // platform itself calls on every org app. Framework-free: a TanStack Start
3
+ // server route, a Next route handler, or a bare Node server can mount these
4
+ // verbatim — which is the point, because the platform's probes must behave
5
+ // identically on every stack.
6
+ import postgres from "postgres";
7
+ import { withDbRetry } from "./db-retry.js";
8
+ export { withDbRetry, isTransientConnectionError, DB_RETRY_ATTEMPTS } from "./db-retry.js";
9
+ export { createBuildMarkerHandler } from "./build-marker.js";
10
+ export { createSentryTunnelHandler } from "./sentry-tunnel.js";
11
+ /**
12
+ * GET /api/health — the readiness gate. The sandbox refuses to report an
13
+ * instance as up, and Plaza refuses to wake one, until this answers ok. It
14
+ * proves the one dependency that page loads actually need: the database
15
+ * answers a query. A transient DNS fault must not read as an unhealthy app,
16
+ * hence the connect retry.
17
+ */
18
+ export function createHealthHandler(options = {}) {
19
+ const databaseUrl = options.databaseUrl ?? (() => process.env.DATABASE_URL);
20
+ const orgId = options.orgId ?? (() => process.env.MII_ORG_ID);
21
+ return async function health() {
22
+ const url = databaseUrl();
23
+ if (!url) {
24
+ return Response.json({ ok: false, error: "DATABASE_URL is not configured" }, { status: 503 });
25
+ }
26
+ const sql = postgres(url, { max: 1, prepare: false, connect_timeout: 10 });
27
+ try {
28
+ const [result] = await withDbRetry(() => sql `
29
+ select current_database() as database, now()::text as now
30
+ `, options.retry);
31
+ return Response.json({
32
+ ok: true,
33
+ orgId: orgId() ?? null,
34
+ database: result?.database ?? null,
35
+ databaseTime: result?.now ?? null
36
+ });
37
+ }
38
+ catch (err) {
39
+ return Response.json({ ok: false, error: err instanceof Error ? err.message : "Unknown error" }, { status: 503 });
40
+ }
41
+ finally {
42
+ await sql.end();
43
+ }
44
+ };
45
+ }
@@ -0,0 +1,6 @@
1
+ export type SentryTunnelOptions = {
2
+ /** Test hook; defaults to global fetch. */
3
+ readonly fetch?: typeof fetch;
4
+ };
5
+ /** POST for /monitoring: forward the envelope, answer what Sentry answered. */
6
+ export declare function createSentryTunnelHandler(options?: SentryTunnelOptions): (request: Request) => Promise<Response>;
@@ -0,0 +1,43 @@
1
+ // The Sentry tunnel: browsers post error envelopes to this app's own
2
+ // /monitoring route, which forwards them to Sentry — because ad-blockers
3
+ // block *.sentry.io from the browser, and a platform whose error reporting
4
+ // dies to a browser extension is blind exactly where it matters.
5
+ //
6
+ // The envelope's own header names the DSN, so the route needs no
7
+ // configuration; it forwards only to hosts that are plainly Sentry's, so an
8
+ // app cannot be turned into an open relay by a crafted envelope.
9
+ const ALLOWED_HOST = /(^|\.)(sentry\.io|ingest\.sentry\.io|ingest\.[a-z0-9-]+\.sentry\.io)$/;
10
+ /** POST for /monitoring: forward the envelope, answer what Sentry answered. */
11
+ export function createSentryTunnelHandler(options = {}) {
12
+ const doFetch = options.fetch ?? fetch;
13
+ return async function tunnel(request) {
14
+ const envelope = await request.text();
15
+ const newline = envelope.indexOf("\n");
16
+ const headerLine = newline === -1 ? envelope : envelope.slice(0, newline);
17
+ let dsn;
18
+ try {
19
+ const header = JSON.parse(headerLine);
20
+ if (typeof header.dsn !== "string") {
21
+ return Response.json({ error: "Envelope names no DSN." }, { status: 400 });
22
+ }
23
+ dsn = new URL(header.dsn);
24
+ }
25
+ catch {
26
+ return Response.json({ error: "Not a Sentry envelope." }, { status: 400 });
27
+ }
28
+ if (!ALLOWED_HOST.test(dsn.hostname)) {
29
+ // A DSN pointing anywhere else would make this route an open relay.
30
+ return Response.json({ error: "DSN host is not Sentry." }, { status: 400 });
31
+ }
32
+ const projectId = dsn.pathname.replace(/^\/+/, "");
33
+ if (!/^\d+$/.test(projectId)) {
34
+ return Response.json({ error: "DSN names no project." }, { status: 400 });
35
+ }
36
+ const upstream = await doFetch(`https://${dsn.hostname}/api/${projectId}/envelope/`, {
37
+ method: "POST",
38
+ headers: { "Content-Type": "application/x-sentry-envelope" },
39
+ body: envelope
40
+ });
41
+ return new Response(null, { status: upstream.status });
42
+ };
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shubh90/app-runtime",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "The platform contract every mii org app depends on — sign-in, and the Next.js config an app must not diverge from. A versioned package, not files copied into each repo.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -27,6 +27,18 @@
27
27
  "./next": {
28
28
  "types": "./dist/next/index.d.ts",
29
29
  "import": "./dist/next/index.js"
30
+ },
31
+ "./platform": {
32
+ "types": "./dist/platform/index.d.ts",
33
+ "import": "./dist/platform/index.js"
34
+ },
35
+ "./server": {
36
+ "types": "./dist/server/index.d.ts",
37
+ "import": "./dist/server/index.js"
38
+ },
39
+ "./react": {
40
+ "types": "./dist/react/index.d.ts",
41
+ "import": "./dist/react/index.js"
30
42
  }
31
43
  },
32
44
  "scripts": {
@@ -37,17 +49,23 @@
37
49
  },
38
50
  "peerDependencies": {
39
51
  "next": ">=15",
40
- "postgres": ">=3"
52
+ "postgres": ">=3",
53
+ "react": ">=18"
41
54
  },
42
55
  "peerDependenciesMeta": {
43
56
  "next": {
44
57
  "optional": true
58
+ },
59
+ "react": {
60
+ "optional": "true"
45
61
  }
46
62
  },
47
63
  "devDependencies": {
48
64
  "@types/node": "^22",
65
+ "@types/react": "^19",
49
66
  "next": "^15",
50
67
  "postgres": "^3.4.9",
68
+ "react": "^19",
51
69
  "tsx": "^4",
52
70
  "typescript": "^5.6"
53
71
  }