@aperture-browser/session-relay 0.0.0-nightly.100.78b4e7ef6edc
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/dist/index.d.mts +34 -0
- package/dist/index.mjs +230 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node.d.mts +8 -0
- package/dist/node.mjs +10 -0
- package/dist/node.mjs.map +1 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Taras Fomin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# @aperture-browser/session-relay
|
|
2
|
+
|
|
3
|
+
Relay Aperture session access through your application's backend. Your application authenticates the browser and decides which session it may access; Aperture credentials stay on the backend.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @aperture-browser/session-relay effect @effect/platform-node
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Mount the relay
|
|
10
|
+
|
|
11
|
+
`sessionRelay` registers six GET routes on an Effect `HttpRouter` below `<prefix>/sessions/:sessionId`: `/browser/status`, `/session`, `/webrtc/signal`, `/recordings/:recordingId/content`, `/thumbnail`, and `/targets/:targetId/thumbnail`. `/browser/status` is passive; `/session` and `/webrtc/signal` upgrade to WebSockets and may wake a suspended session. The Node adapter supplies upstream HTTP and WebSocket clients; it does not start a server.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import * as Layer from "effect/Layer";
|
|
15
|
+
import { sessionRelay, type SessionRelayOptions } from "@aperture-browser/session-relay";
|
|
16
|
+
import { nodeRelayLayer } from "@aperture-browser/session-relay/node";
|
|
17
|
+
|
|
18
|
+
export function relayRoutes(authorize: SessionRelayOptions["authorize"]) {
|
|
19
|
+
return sessionRelay({
|
|
20
|
+
prefix: "/aperture",
|
|
21
|
+
publicOrigin: "https://app.example.com",
|
|
22
|
+
apertureBaseUrl: "https://aperture.example.com",
|
|
23
|
+
authorize,
|
|
24
|
+
}).pipe(Layer.provide(nodeRelayLayer));
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Merge the returned layer with your app's other routes and serve it with `HttpRouter.serve` and `NodeHttpServer`. Existing Node HTTP servers can use `NodeHttpServer.makeHandler` and `makeUpgradeHandler` for the corresponding request and upgrade events. Configure the server's WebSocket `maxPayload` for your expected raster frames, for example 16 MiB. Your ingress must support persistent WebSocket upgrades.
|
|
29
|
+
|
|
30
|
+
## Authorize every request
|
|
31
|
+
|
|
32
|
+
The callback receives the original `HttpServerRequest` and the requested session identifier. Read your app's login cookie, verify the user's session access, and return an Effect containing:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import * as Effect from "effect/Effect";
|
|
36
|
+
import * as Redacted from "effect/Redacted";
|
|
37
|
+
import { RelayDenied, type RelayGrant } from "@aperture-browser/session-relay";
|
|
38
|
+
|
|
39
|
+
// After your application's authentication and authorization have succeeded:
|
|
40
|
+
export function grantSession(
|
|
41
|
+
sessionId: string,
|
|
42
|
+
capability: Redacted.Redacted<string>,
|
|
43
|
+
signal: AbortSignal,
|
|
44
|
+
): Effect.Effect<RelayGrant> {
|
|
45
|
+
return Effect.succeed({ sessionId, capability, signal });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Return these from the authorization callback for login/access failures:
|
|
49
|
+
export const signInRequired = Effect.fail(new RelayDenied({ status: 401 }));
|
|
50
|
+
export const accessDenied = Effect.fail(new RelayDenied({ status: 403 }));
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Select the session's `collaboration.editorToken` or `collaboration.viewerToken` from your server-side `ApertureClient.sessions.getSession` result according to the app user's permissions. An `aps_` owner token or account API token is rejected as a relay grant. The browser cannot select its role. Aperture enforces that role on both WebSockets and direct WebRTC data channels.
|
|
54
|
+
|
|
55
|
+
The requested identifier is one path segment containing letters, digits, underscores, or hyphens. It may be an app-owned alias; the callback maps it to the actual Aperture `sessionId`. Every status request, fallback connection, and reconnect calls the callback again. Authentication callbacks may depend on Effect services; supply those services to the served router.
|
|
56
|
+
|
|
57
|
+
Own an `AbortController` for each access lifetime. Return its signal for all connections belonging to that access. Abort it when login expires, the user logs out, or their access is revoked or downgraded. This closes active signaling/session connections and their upstream peers. Reject later callback invocations until the user has access again. Use a fresh controller for a newly granted lifetime. Merely checking login when a socket opens does not revoke an already connected browser.
|
|
58
|
+
|
|
59
|
+
## Connect the browser
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
import { ApertureSession } from "@aperture-browser/session-react";
|
|
63
|
+
import "@aperture-browser/session-react/styles.css";
|
|
64
|
+
|
|
65
|
+
const access = { kind: "relay", baseUrl: "/aperture", sessionId: "your-session-id" } as const;
|
|
66
|
+
|
|
67
|
+
export function Browser() {
|
|
68
|
+
return <ApertureSession access={access} className="h-full" />;
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
For custom elements, assign the same object to `element.access`. Headless React uses `useSession({ access })`. Keep the access object stable across renders. No token or role is supplied to the browser; same-origin cookies authenticate it to the consumer app.
|
|
73
|
+
|
|
74
|
+
## Connection behavior
|
|
75
|
+
|
|
76
|
+
- Status and both WebSockets use the relay mount. Status authorization is forwarded directly without a live-session preflight and does not wake or touch retention. WebSocket connection remains the action that may wake a suspended session. WebRTC video and data channels connect directly to Aperture, subject to its ICE/TURN connectivity. Raster fallback travels through the relay.
|
|
77
|
+
- Status uses `source: "live"` for a running snapshot, `source: "persisted"` for the page and thumbnail generation saved at suspension, and `source: "unavailable"` when no historical page generation exists. Persisted target IDs may change after resume.
|
|
78
|
+
- Editors download stopped recordings through the relay. Aperture rejects viewer grants, and aborting the access signal ends a download in progress. The file is streamed, not buffered.
|
|
79
|
+
- Session and tab thumbnails pass through the relay for both roles. They never wake a suspended session, which serves the thumbnails saved when it suspended. `If-Modified-Since` is forwarded, so unchanged thumbnails answer 304.
|
|
80
|
+
- The relay checks browser Origin against the configured public origin before authentication. It constructs upstream requests afresh, sending only the selected capability and session subprotocol. Browser cookies, Origin, authorization, and tenant headers are not forwarded. `embed_allowed_origins` needs no entry for the app.
|
|
81
|
+
- Upstream status authorization is checked before each WebSocket upgrade. Denials become close codes 4401/4403; missing or expired sessions use 4404/4410. All four stop client retries. Restore login/access before calling `reconnect()` explicitly. Transient transport failures retain normal fallback and reconnect behavior.
|
|
82
|
+
- Message boundaries and text/binary framing are preserved. Effect sockets apply backpressure; either connection closing disposes the other. No session protocol interpretation or input filtering happens in the relay.
|
|
83
|
+
- HTTP responses are not cacheable. The upstream HTTP client must not follow redirects or inject unrelated credentials; `nodeRelayLayer` supplies an undecorated client.
|
|
84
|
+
|
|
85
|
+
Only viewer/editor live-session access, recording downloads, and thumbnails are exposed. Session management, raw CDP, and files are separate backend integrations.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Redacted from "effect/Redacted";
|
|
3
|
+
import * as Schema from "effect/Schema";
|
|
4
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
5
|
+
import * as HttpRouter from "effect/http/HttpRouter";
|
|
6
|
+
import * as HttpServerRequest from "effect/http/HttpServerRequest";
|
|
7
|
+
import * as Socket from "effect/socket/Socket";
|
|
8
|
+
|
|
9
|
+
//#region src/relay.d.ts
|
|
10
|
+
declare const RelayDenied_base: Schema.Class<RelayDenied, Schema.TaggedStruct<"RelayDenied", {
|
|
11
|
+
readonly status: Schema.Literals<readonly [401, 403]>;
|
|
12
|
+
}>, import("effect/Cause").YieldableError>;
|
|
13
|
+
declare class RelayDenied extends RelayDenied_base {}
|
|
14
|
+
/** A consumer-authorized connection. Aborting the signal revokes active access. */
|
|
15
|
+
interface RelayGrant {
|
|
16
|
+
readonly sessionId: string;
|
|
17
|
+
/** An editor (ape_) or viewer (apv_) capability, never an owner or account token. */
|
|
18
|
+
readonly capability: Redacted.Redacted<string>;
|
|
19
|
+
readonly signal: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
interface SessionRelayOptions<R = never> {
|
|
22
|
+
readonly prefix: string;
|
|
23
|
+
readonly publicOrigin: string;
|
|
24
|
+
readonly apertureBaseUrl: string;
|
|
25
|
+
readonly authorize: (request: HttpServerRequest.HttpServerRequest, sessionId: string) => Effect.Effect<RelayGrant, RelayDenied, R>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Registers session status, the session and signaling WebSockets, recording downloads, and
|
|
29
|
+
* thumbnails; the consumer owns login.
|
|
30
|
+
*/
|
|
31
|
+
declare function sessionRelay<R>(options: SessionRelayOptions<R>): import("effect/Layer").Layer<never, never, HttpClient.HttpClient | Socket.WebSocketConstructor | HttpRouter.HttpRouter | HttpRouter.Request<"Error", import("effect/http/HttpServerError").HttpServerError> | HttpRouter.Request<"Error", Socket.SocketError> | Exclude<HttpRouter.Request.From<"Requires", Exclude<Exclude<R, import("effect/Scope").Scope>, HttpRouter.Provided>>, import("effect/Scope").Scope>>;
|
|
32
|
+
//#endregion
|
|
33
|
+
export { RelayDenied, RelayGrant, SessionRelayOptions, sessionRelay };
|
|
34
|
+
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import * as Data from "effect/Data";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Redacted from "effect/Redacted";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
5
|
+
import * as Stream from "effect/Stream";
|
|
6
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
7
|
+
import * as HttpRouter from "effect/http/HttpRouter";
|
|
8
|
+
import * as HttpServerRequest from "effect/http/HttpServerRequest";
|
|
9
|
+
import * as HttpServerResponse from "effect/http/HttpServerResponse";
|
|
10
|
+
import * as Socket from "effect/socket/Socket";
|
|
11
|
+
//#region src/relay.ts
|
|
12
|
+
const protocol = "aperture-session.v1";
|
|
13
|
+
const pathParams = Schema.Struct({ sessionId: Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9_-]+$/)) });
|
|
14
|
+
var RelayDenied = class extends Schema.TaggedError()("RelayDenied", { status: Schema.Literals([401, 403]) }) {};
|
|
15
|
+
const isTerminalStatus = (status) => status === 401 || status === 403 || status === 404 || status === 410;
|
|
16
|
+
const recordingParams = Schema.Struct({ recordingId: Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9_-]+$/)) });
|
|
17
|
+
const targetParams = Schema.Struct({ targetId: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9_-]{1,128}$/))) });
|
|
18
|
+
/** Ends a handler early with a finished response. */
|
|
19
|
+
var Respond = class extends Data.TaggedError("Respond") {};
|
|
20
|
+
const respond = (response) => Effect.fail(new Respond({ response }));
|
|
21
|
+
/**
|
|
22
|
+
* Registers session status, the session and signaling WebSockets, recording downloads, and
|
|
23
|
+
* thumbnails; the consumer owns login.
|
|
24
|
+
*/
|
|
25
|
+
function sessionRelay(options) {
|
|
26
|
+
const publicOrigin = new URL(options.publicOrigin).origin;
|
|
27
|
+
const upstream = parseUpstream(options.apertureBaseUrl);
|
|
28
|
+
if (!options.prefix.startsWith("/") || /[?#]/.test(options.prefix)) throw new Error("relay prefix must be an absolute path");
|
|
29
|
+
const prefix = options.prefix.replace(/\/$/, "");
|
|
30
|
+
const upstreamURL = (sessionId, route) => {
|
|
31
|
+
const url = new URL(upstream);
|
|
32
|
+
url.pathname = `${upstream.pathname.replace(/\/$/, "")}/sessions/${encodeURIComponent(sessionId)}/${route}`;
|
|
33
|
+
return url;
|
|
34
|
+
};
|
|
35
|
+
return HttpRouter.use(Effect.fn("sessionRelay.routes")(function* (router) {
|
|
36
|
+
const http = yield* HttpClient.HttpClient;
|
|
37
|
+
const makeSocket = yield* Socket.WebSocketConstructor;
|
|
38
|
+
const upstreamGet = (url, capability, headers = {}) => http.get(url.toString(), { headers: {
|
|
39
|
+
...headers,
|
|
40
|
+
Authorization: `Bearer ${capability}`
|
|
41
|
+
} }).pipe(Effect.timeout("15 seconds"), Effect.provideService(HttpClient.TracerPropagationEnabled, false));
|
|
42
|
+
/** Checks the browser request and asks the consumer for a grant. */
|
|
43
|
+
const admit = Effect.fnUntraced(function* (websocket) {
|
|
44
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
45
|
+
const origin = request.headers.origin;
|
|
46
|
+
if (origin !== publicOrigin && (websocket || origin !== void 0)) return yield* respond(relayError(403, "origin_denied", "Request origin is not allowed"));
|
|
47
|
+
if (websocket && request.headers["sec-websocket-protocol"] !== protocol) return yield* respond(relayError(400, "invalid_protocol", "Session subprotocol is required"));
|
|
48
|
+
const { sessionId } = yield* HttpRouter.schemaPathParams(pathParams);
|
|
49
|
+
const grant = yield* options.authorize(request, sessionId);
|
|
50
|
+
const capability = Redacted.value(grant.capability);
|
|
51
|
+
if (!capability.startsWith("ape_") && !capability.startsWith("apv_")) return yield* respond(relayError(500, "invalid_relay_grant", "Relay requires an editor or viewer capability"));
|
|
52
|
+
if (grant.signal.aborted) return yield* respond(yield* rejectAccess(request, websocket, 403));
|
|
53
|
+
return {
|
|
54
|
+
request,
|
|
55
|
+
grant,
|
|
56
|
+
capability
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
/** Reads passive upstream status, turning refusals into the browser's terminal responses. */
|
|
60
|
+
const readUpstreamStatus = Effect.fnUntraced(function* (admitted, websocket) {
|
|
61
|
+
const { request, grant, capability } = admitted;
|
|
62
|
+
const response = yield* upstreamGet(upstreamURL(grant.sessionId, "browser/status"), capability);
|
|
63
|
+
if (isTerminalStatus(response.status)) return yield* respond(yield* rejectAccess(request, websocket, response.status));
|
|
64
|
+
if (response.status !== 200) {
|
|
65
|
+
const status = response.status >= 400 ? response.status : 502;
|
|
66
|
+
return yield* respond(relayError(status, "session_unavailable", "Upstream session is unavailable"));
|
|
67
|
+
}
|
|
68
|
+
const body = yield* response.arrayBuffer.pipe(Effect.timeout("15 seconds"));
|
|
69
|
+
if (grant.signal.aborted) return yield* respond(yield* rejectAccess(request, websocket, 403));
|
|
70
|
+
return body;
|
|
71
|
+
});
|
|
72
|
+
const status = Effect.gen(function* () {
|
|
73
|
+
const body = yield* readUpstreamStatus(yield* admit(false), false);
|
|
74
|
+
return HttpServerResponse.uint8Array(new Uint8Array(body), {
|
|
75
|
+
contentType: "application/json",
|
|
76
|
+
headers: { "cache-control": "no-store" }
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
const socket = (route) => Effect.gen(function* () {
|
|
80
|
+
const admitted = yield* admit(true);
|
|
81
|
+
yield* readUpstreamStatus(admitted, true);
|
|
82
|
+
const url = upstreamURL(admitted.grant.sessionId, route);
|
|
83
|
+
url.protocol = upstream.protocol === "https:" ? "wss:" : "ws:";
|
|
84
|
+
yield* relaySocket(admitted.request, url, admitted.capability, admitted.grant.signal).pipe(Effect.provideService(Socket.WebSocketConstructor, makeSocket));
|
|
85
|
+
return HttpServerResponse.empty();
|
|
86
|
+
});
|
|
87
|
+
const recording = Effect.gen(function* () {
|
|
88
|
+
const { request, grant, capability } = yield* admit(false);
|
|
89
|
+
const { recordingId } = yield* HttpRouter.schemaPathParams(recordingParams);
|
|
90
|
+
const url = upstreamURL(grant.sessionId, `recordings/${encodeURIComponent(recordingId)}/content`);
|
|
91
|
+
const response = yield* upstreamGet(url, capability);
|
|
92
|
+
if (response.status === 404) return relayError(404, "recording_not_found", "Recording not found");
|
|
93
|
+
if (response.status === 409) return relayError(409, "recording_not_ready", "Recording is not ready for download");
|
|
94
|
+
if (isTerminalStatus(response.status)) return yield* rejectAccess(request, false, response.status);
|
|
95
|
+
if (response.status !== 200) return relayError(502, "upstream_unavailable", "Aperture is unavailable");
|
|
96
|
+
const headers = { "cache-control": "no-store" };
|
|
97
|
+
const disposition = response.headers["content-disposition"];
|
|
98
|
+
if (disposition !== void 0) headers["content-disposition"] = disposition;
|
|
99
|
+
const contentLength = Number(response.headers["content-length"]);
|
|
100
|
+
return HttpServerResponse.stream(response.stream.pipe(Stream.interruptWhen(revoked(grant.signal))), {
|
|
101
|
+
contentType: response.headers["content-type"] ?? "application/octet-stream",
|
|
102
|
+
contentLength: Number.isSafeInteger(contentLength) ? contentLength : void 0,
|
|
103
|
+
headers
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
const thumbnail = Effect.gen(function* () {
|
|
107
|
+
const { request, grant, capability } = yield* admit(false);
|
|
108
|
+
const { targetId } = yield* HttpRouter.schemaPathParams(targetParams);
|
|
109
|
+
const route = targetId === void 0 ? "thumbnail" : `targets/${encodeURIComponent(targetId)}/thumbnail`;
|
|
110
|
+
const ifModifiedSince = request.headers["if-modified-since"];
|
|
111
|
+
const response = yield* upstreamGet(upstreamURL(grant.sessionId, route), capability, ifModifiedSince === void 0 ? {} : { "if-modified-since": ifModifiedSince });
|
|
112
|
+
const headers = { "cache-control": "private, no-cache" };
|
|
113
|
+
const lastModified = response.headers["last-modified"];
|
|
114
|
+
if (lastModified !== void 0) headers["last-modified"] = lastModified;
|
|
115
|
+
if (response.status === 304) return HttpServerResponse.empty({
|
|
116
|
+
status: 304,
|
|
117
|
+
headers
|
|
118
|
+
});
|
|
119
|
+
if (response.status === 404) return relayError(404, "thumbnail_not_found", "Thumbnail not found");
|
|
120
|
+
if (isTerminalStatus(response.status)) return yield* rejectAccess(request, false, response.status);
|
|
121
|
+
if (response.status !== 200) return relayError(502, "upstream_unavailable", "Aperture is unavailable");
|
|
122
|
+
const image = yield* response.arrayBuffer.pipe(Effect.timeout("15 seconds"));
|
|
123
|
+
return HttpServerResponse.uint8Array(new Uint8Array(image), {
|
|
124
|
+
contentType: "image/jpeg",
|
|
125
|
+
headers
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
const routes = [
|
|
129
|
+
{
|
|
130
|
+
path: "browser/status",
|
|
131
|
+
websocket: false,
|
|
132
|
+
handler: status
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
path: "session",
|
|
136
|
+
websocket: true,
|
|
137
|
+
handler: socket("session")
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
path: "webrtc/signal",
|
|
141
|
+
websocket: true,
|
|
142
|
+
handler: socket("webrtc/signal")
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
path: "recordings/:recordingId/content",
|
|
146
|
+
websocket: false,
|
|
147
|
+
handler: recording
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
path: "thumbnail",
|
|
151
|
+
websocket: false,
|
|
152
|
+
handler: thumbnail
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
path: "targets/:targetId/thumbnail",
|
|
156
|
+
websocket: false,
|
|
157
|
+
handler: thumbnail
|
|
158
|
+
}
|
|
159
|
+
];
|
|
160
|
+
const prefixed = router.prefixed(prefix);
|
|
161
|
+
for (const { path, websocket, handler } of routes) yield* prefixed.add("GET", `/sessions/:sessionId/${path}`, handler.pipe(Effect.catchTags({
|
|
162
|
+
Respond: (early) => Effect.succeed(early.response),
|
|
163
|
+
RelayDenied: (error) => Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) => rejectAccess(request, websocket, error.status)),
|
|
164
|
+
SchemaError: () => Effect.succeed(relayError(400, "invalid_path", "Invalid session or recording identifier")),
|
|
165
|
+
TimeoutError: () => Effect.succeed(relayError(504, "upstream_timeout", "Aperture did not respond in time")),
|
|
166
|
+
HttpClientError: () => Effect.succeed(relayError(502, "upstream_unavailable", "Aperture is unavailable"))
|
|
167
|
+
}), Effect.scoped));
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
function parseUpstream(apertureBaseUrl) {
|
|
171
|
+
const upstream = new URL(apertureBaseUrl);
|
|
172
|
+
if (upstream.protocol !== "http:" && upstream.protocol !== "https:") throw new Error("apertureBaseUrl must use HTTP or HTTPS");
|
|
173
|
+
if (upstream.username !== "" || upstream.password !== "" || upstream.search !== "" || upstream.hash !== "") throw new Error("apertureBaseUrl must not contain credentials, a query, or a fragment");
|
|
174
|
+
return upstream;
|
|
175
|
+
}
|
|
176
|
+
function relayError(status, code, message) {
|
|
177
|
+
return HttpServerResponse.jsonUnsafe({ error: {
|
|
178
|
+
code,
|
|
179
|
+
message
|
|
180
|
+
} }, {
|
|
181
|
+
status,
|
|
182
|
+
headers: { "cache-control": "no-store" }
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const rejectAccess = Effect.fn("sessionRelay.rejectAccess")(function* (request, websocket, status) {
|
|
186
|
+
const denied = status === 401 || status === 403;
|
|
187
|
+
if (!websocket) return denied ? relayError(status, "access_denied", "Session access denied") : relayError(status, "session_unavailable", "Session unavailable");
|
|
188
|
+
const socket = yield* request.upgrade;
|
|
189
|
+
yield* socket.reader;
|
|
190
|
+
yield* (yield* socket.writer).write(new Socket.CloseEvent(4e3 + status, denied ? "Session access denied" : "Session unavailable"));
|
|
191
|
+
return HttpServerResponse.empty();
|
|
192
|
+
});
|
|
193
|
+
/** Upgrades the browser request and pipes it to Aperture until either side closes or access is revoked. */
|
|
194
|
+
const relaySocket = Effect.fn("sessionRelay.relaySocket")(function* (request, url, capability, signal) {
|
|
195
|
+
const downstream = yield* request.upgrade;
|
|
196
|
+
const writer = yield* downstream.writer;
|
|
197
|
+
const upstream = yield* Socket.makeWebSocket(url.toString(), {
|
|
198
|
+
protocols: [protocol, `authorization.bearer.${capability}`],
|
|
199
|
+
openTimeout: "10 seconds",
|
|
200
|
+
highWaterMark: 1024 * 1024
|
|
201
|
+
});
|
|
202
|
+
const closeRevoked = Effect.gen(function* () {
|
|
203
|
+
yield* revoked(signal);
|
|
204
|
+
yield* writer.write(new Socket.CloseEvent(4403, "Session access revoked"));
|
|
205
|
+
});
|
|
206
|
+
yield* bridge(downstream, upstream).pipe(Effect.raceFirst(closeRevoked), Effect.catchTag("SocketError", (error) => {
|
|
207
|
+
const { reason } = error;
|
|
208
|
+
const code = reason._tag === "SocketCloseError" && (reason.code === 1e3 || reason.code === 1001) ? reason.code : 1011;
|
|
209
|
+
return writer.write(new Socket.CloseEvent(code, "Session transport closed")).pipe(Effect.catchTag("SocketError", () => Effect.void));
|
|
210
|
+
}));
|
|
211
|
+
});
|
|
212
|
+
const bridge = Effect.fn("sessionRelay.bridge")(function* (downstream, upstream) {
|
|
213
|
+
const [client, server] = yield* Effect.all([downstream.reader, upstream.reader], { concurrency: "unbounded" });
|
|
214
|
+
const [toClient, toServer] = yield* Effect.all([downstream.writer, upstream.writer]);
|
|
215
|
+
yield* Effect.raceFirst(client.pull.pipe(Effect.flatMap(toServer.writeAll), Effect.forever), server.pull.pipe(Effect.flatMap(toClient.writeAll), Effect.forever));
|
|
216
|
+
});
|
|
217
|
+
/** AbortSignal is the consumer's per-user/per-session revocation boundary. */
|
|
218
|
+
const revoked = (signal) => Effect.callback((resume) => {
|
|
219
|
+
if (signal.aborted) {
|
|
220
|
+
resume(Effect.void);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const abort = () => resume(Effect.void);
|
|
224
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
225
|
+
return Effect.sync(() => signal.removeEventListener("abort", abort));
|
|
226
|
+
});
|
|
227
|
+
//#endregion
|
|
228
|
+
export { RelayDenied, sessionRelay };
|
|
229
|
+
|
|
230
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/relay.ts"],"sourcesContent":["import * as Data from \"effect/Data\";\nimport * as Effect from \"effect/Effect\";\nimport * as Redacted from \"effect/Redacted\";\nimport * as Schema from \"effect/Schema\";\nimport * as Stream from \"effect/Stream\";\nimport * as HttpClient from \"effect/http/HttpClient\";\nimport * as HttpRouter from \"effect/http/HttpRouter\";\nimport * as HttpServerRequest from \"effect/http/HttpServerRequest\";\nimport * as HttpServerResponse from \"effect/http/HttpServerResponse\";\nimport * as Socket from \"effect/socket/Socket\";\n\nconst protocol = \"aperture-session.v1\";\nconst pathParams = Schema.Struct({\n sessionId: Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9_-]+$/)),\n});\n\nexport class RelayDenied extends Schema.TaggedError<RelayDenied>()(\"RelayDenied\", {\n status: Schema.Literals([401, 403]),\n}) {}\n\n/** A consumer-authorized connection. Aborting the signal revokes active access. */\nexport interface RelayGrant {\n readonly sessionId: string;\n /** An editor (ape_) or viewer (apv_) capability, never an owner or account token. */\n readonly capability: Redacted.Redacted<string>;\n readonly signal: AbortSignal;\n}\n\nexport interface SessionRelayOptions<R = never> {\n readonly prefix: string;\n readonly publicOrigin: string;\n readonly apertureBaseUrl: string;\n readonly authorize: (\n request: HttpServerRequest.HttpServerRequest,\n sessionId: string,\n ) => Effect.Effect<RelayGrant, RelayDenied, R>;\n}\n\ntype SocketRoute = \"session\" | \"webrtc/signal\";\n\n/** Statuses the browser treats as final: it stops retrying until reconnected explicitly. */\ntype TerminalStatus = 401 | 403 | 404 | 410;\nconst isTerminalStatus = (status: number): status is TerminalStatus =>\n status === 401 || status === 403 || status === 404 || status === 410;\n\nconst recordingParams = Schema.Struct({\n recordingId: Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9_-]+$/)),\n});\n\nconst targetParams = Schema.Struct({\n targetId: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9_-]{1,128}$/))),\n});\n\n/** Ends a handler early with a finished response. */\nclass Respond extends Data.TaggedError(\"Respond\")<{\n readonly response: HttpServerResponse.HttpServerResponse;\n}> {}\n\nconst respond = (response: HttpServerResponse.HttpServerResponse) =>\n Effect.fail(new Respond({ response }));\n\n/**\n * Registers session status, the session and signaling WebSockets, recording downloads, and\n * thumbnails; the consumer owns login.\n */\nexport function sessionRelay<R>(options: SessionRelayOptions<R>) {\n const publicOrigin = new URL(options.publicOrigin).origin;\n const upstream = parseUpstream(options.apertureBaseUrl);\n if (!options.prefix.startsWith(\"/\") || /[?#]/.test(options.prefix)) {\n throw new Error(\"relay prefix must be an absolute path\");\n }\n const prefix = options.prefix.replace(/\\/$/, \"\");\n\n const upstreamURL = (sessionId: string, route: string) => {\n const url = new URL(upstream);\n url.pathname = `${upstream.pathname.replace(/\\/$/, \"\")}/sessions/${encodeURIComponent(sessionId)}/${route}`;\n return url;\n };\n\n return HttpRouter.use(\n Effect.fn(\"sessionRelay.routes\")(function* (router) {\n const http = yield* HttpClient.HttpClient;\n const makeSocket = yield* Socket.WebSocketConstructor;\n\n const upstreamGet = (url: URL, capability: string, headers: Record<string, string> = {}) =>\n http\n .get(url.toString(), { headers: { ...headers, Authorization: `Bearer ${capability}` } })\n .pipe(\n Effect.timeout(\"15 seconds\"),\n Effect.provideService(HttpClient.TracerPropagationEnabled, false),\n );\n\n /** Checks the browser request and asks the consumer for a grant. */\n const admit = Effect.fnUntraced(function* (websocket: boolean) {\n const request = yield* HttpServerRequest.HttpServerRequest;\n // Browsers always send Origin on WebSocket upgrades, but may omit it on same-origin GETs.\n const origin = request.headers.origin;\n if (origin !== publicOrigin && (websocket || origin !== undefined)) {\n return yield* respond(relayError(403, \"origin_denied\", \"Request origin is not allowed\"));\n }\n if (websocket && request.headers[\"sec-websocket-protocol\"] !== protocol) {\n return yield* respond(\n relayError(400, \"invalid_protocol\", \"Session subprotocol is required\"),\n );\n }\n\n const { sessionId } = yield* HttpRouter.schemaPathParams(pathParams);\n const grant = yield* options.authorize(request, sessionId);\n const capability = Redacted.value(grant.capability);\n if (!capability.startsWith(\"ape_\") && !capability.startsWith(\"apv_\")) {\n return yield* respond(\n relayError(500, \"invalid_relay_grant\", \"Relay requires an editor or viewer capability\"),\n );\n }\n if (grant.signal.aborted) {\n return yield* respond(yield* rejectAccess(request, websocket, 403));\n }\n return { request, grant, capability };\n });\n\n /** Reads passive upstream status, turning refusals into the browser's terminal responses. */\n const readUpstreamStatus = Effect.fnUntraced(function* (\n admitted: Effect.Success<ReturnType<typeof admit>>,\n websocket: boolean,\n ) {\n const { request, grant, capability } = admitted;\n const response = yield* upstreamGet(\n upstreamURL(grant.sessionId, \"browser/status\"),\n capability,\n );\n if (isTerminalStatus(response.status)) {\n return yield* respond(yield* rejectAccess(request, websocket, response.status));\n }\n if (response.status !== 200) {\n const status = response.status >= 400 ? response.status : 502;\n return yield* respond(\n relayError(status, \"session_unavailable\", \"Upstream session is unavailable\"),\n );\n }\n const body = yield* response.arrayBuffer.pipe(Effect.timeout(\"15 seconds\"));\n if (grant.signal.aborted) {\n return yield* respond(yield* rejectAccess(request, websocket, 403));\n }\n return body;\n });\n\n // This is the requested resource itself, not a live-session authorization preflight.\n const status = Effect.gen(function* () {\n const body = yield* readUpstreamStatus(yield* admit(false), false);\n return HttpServerResponse.uint8Array(new Uint8Array(body), {\n contentType: \"application/json\",\n headers: { \"cache-control\": \"no-store\" },\n });\n });\n\n // Check upstream authorization before upgrading: browser WebSockets cannot expose HTTP status.\n const socket = (route: SocketRoute) =>\n Effect.gen(function* () {\n const admitted = yield* admit(true);\n yield* readUpstreamStatus(admitted, true);\n const url = upstreamURL(admitted.grant.sessionId, route);\n url.protocol = upstream.protocol === \"https:\" ? \"wss:\" : \"ws:\";\n yield* relaySocket(\n admitted.request,\n url,\n admitted.capability,\n admitted.grant.signal,\n ).pipe(Effect.provideService(Socket.WebSocketConstructor, makeSocket));\n return HttpServerResponse.empty();\n });\n\n // Aperture itself restricts recordings to editor capabilities.\n const recording = Effect.gen(function* () {\n const { request, grant, capability } = yield* admit(false);\n const { recordingId } = yield* HttpRouter.schemaPathParams(recordingParams);\n const url = upstreamURL(\n grant.sessionId,\n `recordings/${encodeURIComponent(recordingId)}/content`,\n );\n const response = yield* upstreamGet(url, capability);\n if (response.status === 404) {\n return relayError(404, \"recording_not_found\", \"Recording not found\");\n }\n if (response.status === 409) {\n return relayError(409, \"recording_not_ready\", \"Recording is not ready for download\");\n }\n if (isTerminalStatus(response.status)) {\n return yield* rejectAccess(request, false, response.status);\n }\n if (response.status !== 200) {\n return relayError(502, \"upstream_unavailable\", \"Aperture is unavailable\");\n }\n const headers: Record<string, string> = { \"cache-control\": \"no-store\" };\n const disposition = response.headers[\"content-disposition\"];\n if (disposition !== undefined) headers[\"content-disposition\"] = disposition;\n const contentLength = Number(response.headers[\"content-length\"]);\n return HttpServerResponse.stream(\n response.stream.pipe(Stream.interruptWhen(revoked(grant.signal))),\n {\n contentType: response.headers[\"content-type\"] ?? \"application/octet-stream\",\n contentLength: Number.isSafeInteger(contentLength) ? contentLength : undefined,\n headers,\n },\n );\n });\n\n // Aperture serves thumbnails without waking a suspended session, so hovers stay cheap.\n const thumbnail = Effect.gen(function* () {\n const { request, grant, capability } = yield* admit(false);\n const { targetId } = yield* HttpRouter.schemaPathParams(targetParams);\n const route =\n targetId === undefined\n ? \"thumbnail\"\n : `targets/${encodeURIComponent(targetId)}/thumbnail`;\n const ifModifiedSince = request.headers[\"if-modified-since\"];\n const response = yield* upstreamGet(\n upstreamURL(grant.sessionId, route),\n capability,\n ifModifiedSince === undefined ? {} : { \"if-modified-since\": ifModifiedSince },\n );\n const headers: Record<string, string> = { \"cache-control\": \"private, no-cache\" };\n const lastModified = response.headers[\"last-modified\"];\n if (lastModified !== undefined) headers[\"last-modified\"] = lastModified;\n if (response.status === 304) {\n return HttpServerResponse.empty({ status: 304, headers });\n }\n if (response.status === 404) {\n return relayError(404, \"thumbnail_not_found\", \"Thumbnail not found\");\n }\n if (isTerminalStatus(response.status)) {\n return yield* rejectAccess(request, false, response.status);\n }\n if (response.status !== 200) {\n return relayError(502, \"upstream_unavailable\", \"Aperture is unavailable\");\n }\n const image = yield* response.arrayBuffer.pipe(Effect.timeout(\"15 seconds\"));\n return HttpServerResponse.uint8Array(new Uint8Array(image), {\n contentType: \"image/jpeg\",\n headers,\n });\n });\n\n const routes = [\n { path: \"browser/status\", websocket: false, handler: status },\n { path: \"session\", websocket: true, handler: socket(\"session\") },\n { path: \"webrtc/signal\", websocket: true, handler: socket(\"webrtc/signal\") },\n { path: \"recordings/:recordingId/content\", websocket: false, handler: recording },\n { path: \"thumbnail\", websocket: false, handler: thumbnail },\n { path: \"targets/:targetId/thumbnail\", websocket: false, handler: thumbnail },\n ];\n const prefixed = router.prefixed(prefix);\n for (const { path, websocket, handler } of routes) {\n yield* prefixed.add(\n \"GET\",\n `/sessions/:sessionId/${path}`,\n handler.pipe(\n Effect.catchTags({\n Respond: (early) => Effect.succeed(early.response),\n RelayDenied: (error) =>\n Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) =>\n rejectAccess(request, websocket, error.status),\n ),\n SchemaError: () =>\n Effect.succeed(\n relayError(400, \"invalid_path\", \"Invalid session or recording identifier\"),\n ),\n TimeoutError: () =>\n Effect.succeed(\n relayError(504, \"upstream_timeout\", \"Aperture did not respond in time\"),\n ),\n HttpClientError: () =>\n Effect.succeed(relayError(502, \"upstream_unavailable\", \"Aperture is unavailable\")),\n }),\n Effect.scoped,\n ),\n );\n }\n }),\n );\n}\n\nfunction parseUpstream(apertureBaseUrl: string): URL {\n const upstream = new URL(apertureBaseUrl);\n if (upstream.protocol !== \"http:\" && upstream.protocol !== \"https:\") {\n throw new Error(\"apertureBaseUrl must use HTTP or HTTPS\");\n }\n if (\n upstream.username !== \"\" ||\n upstream.password !== \"\" ||\n upstream.search !== \"\" ||\n upstream.hash !== \"\"\n ) {\n throw new Error(\"apertureBaseUrl must not contain credentials, a query, or a fragment\");\n }\n return upstream;\n}\n\nfunction relayError(status: number, code: string, message: string) {\n return HttpServerResponse.jsonUnsafe(\n { error: { code, message } },\n { status, headers: { \"cache-control\": \"no-store\" } },\n );\n}\n\nconst rejectAccess = Effect.fn(\"sessionRelay.rejectAccess\")(function* (\n request: HttpServerRequest.HttpServerRequest,\n websocket: boolean,\n status: TerminalStatus,\n) {\n const denied = status === 401 || status === 403;\n if (!websocket) {\n return denied\n ? relayError(status, \"access_denied\", \"Session access denied\")\n : relayError(status, \"session_unavailable\", \"Session unavailable\");\n }\n const socket = yield* request.upgrade;\n // Acquiring the reader installs the close listeners before sending a close frame.\n yield* socket.reader;\n const writer = yield* socket.writer;\n yield* writer.write(\n new Socket.CloseEvent(4000 + status, denied ? \"Session access denied\" : \"Session unavailable\"),\n );\n return HttpServerResponse.empty();\n});\n\n/** Upgrades the browser request and pipes it to Aperture until either side closes or access is revoked. */\nconst relaySocket = Effect.fn(\"sessionRelay.relaySocket\")(function* (\n request: HttpServerRequest.HttpServerRequest,\n url: URL,\n capability: string,\n signal: AbortSignal,\n) {\n const downstream = yield* request.upgrade;\n const writer = yield* downstream.writer;\n const upstream = yield* Socket.makeWebSocket(url.toString(), {\n protocols: [protocol, `authorization.bearer.${capability}`],\n openTimeout: \"10 seconds\",\n highWaterMark: 1024 * 1024,\n });\n\n const closeRevoked = Effect.gen(function* () {\n yield* revoked(signal);\n yield* writer.write(new Socket.CloseEvent(4403, \"Session access revoked\"));\n });\n\n yield* bridge(downstream, upstream).pipe(\n Effect.raceFirst(closeRevoked),\n Effect.catchTag(\"SocketError\", (error) => {\n // Only clean closes pass through; anything else tells the browser to retry.\n const { reason } = error;\n const code =\n reason._tag === \"SocketCloseError\" && (reason.code === 1000 || reason.code === 1001)\n ? reason.code\n : 1011;\n return writer\n .write(new Socket.CloseEvent(code, \"Session transport closed\"))\n .pipe(Effect.catchTag(\"SocketError\", () => Effect.void));\n }),\n );\n});\n\nconst bridge = Effect.fn(\"sessionRelay.bridge\")(function* (\n downstream: Socket.Socket,\n upstream: Socket.Socket,\n) {\n const [client, server] = yield* Effect.all([downstream.reader, upstream.reader], {\n concurrency: \"unbounded\",\n });\n const [toClient, toServer] = yield* Effect.all([downstream.writer, upstream.writer]);\n yield* Effect.raceFirst(\n client.pull.pipe(Effect.flatMap(toServer.writeAll), Effect.forever),\n server.pull.pipe(Effect.flatMap(toClient.writeAll), Effect.forever),\n );\n});\n\n/** AbortSignal is the consumer's per-user/per-session revocation boundary. */\nconst revoked = (signal: AbortSignal): Effect.Effect<void> =>\n Effect.callback((resume) => {\n if (signal.aborted) {\n resume(Effect.void);\n return;\n }\n const abort = () => resume(Effect.void);\n signal.addEventListener(\"abort\", abort, { once: true });\n return Effect.sync(() => signal.removeEventListener(\"abort\", abort));\n });\n"],"mappings":"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"}
|
package/dist/node.d.mts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as Layer from "effect/Layer";
|
|
2
|
+
|
|
3
|
+
//#region src/node.d.ts
|
|
4
|
+
/** Supply to sessionRelay in a Node HttpRouter application. Does not start a server. */
|
|
5
|
+
declare const nodeRelayLayer: Layer.Layer<import("effect/http/HttpClient").HttpClient | import("effect/socket/Socket").WebSocketConstructor, never, never>;
|
|
6
|
+
//#endregion
|
|
7
|
+
export { nodeRelayLayer };
|
|
8
|
+
//# sourceMappingURL=node.d.mts.map
|
package/dist/node.mjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as Layer from "effect/Layer";
|
|
2
|
+
import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
|
|
3
|
+
import * as NodeSocket from "@effect/platform-node/NodeSocket";
|
|
4
|
+
//#region src/node.ts
|
|
5
|
+
/** Supply to sessionRelay in a Node HttpRouter application. Does not start a server. */
|
|
6
|
+
const nodeRelayLayer = Layer.mergeAll(NodeHttpClient.layerNodeHttp, NodeSocket.layerWebSocketConstructorWS);
|
|
7
|
+
//#endregion
|
|
8
|
+
export { nodeRelayLayer };
|
|
9
|
+
|
|
10
|
+
//# sourceMappingURL=node.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node.mjs","names":[],"sources":["../src/node.ts"],"sourcesContent":["import * as Layer from \"effect/Layer\";\nimport * as NodeHttpClient from \"@effect/platform-node/NodeHttpClient\";\nimport * as NodeSocket from \"@effect/platform-node/NodeSocket\";\n\n/** Supply to sessionRelay in a Node HttpRouter application. Does not start a server. */\nexport const nodeRelayLayer = Layer.mergeAll(\n NodeHttpClient.layerNodeHttp,\n NodeSocket.layerWebSocketConstructorWS,\n);\n"],"mappings":";;;;;AAKA,MAAa,iBAAiB,MAAM,SAClC,eAAe,eACf,WAAW,2BACb"}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aperture-browser/session-relay",
|
|
3
|
+
"version": "0.0.0-nightly.100.78b4e7ef6edc",
|
|
4
|
+
"description": "Authenticated HTTP and WebSocket relay for Aperture sessions, controlled by the consuming backend.",
|
|
5
|
+
"homepage": "https://github.com/tarik02/aperture#readme",
|
|
6
|
+
"bugs": "https://github.com/tarik02/aperture/issues",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/tarik02/aperture.git",
|
|
11
|
+
"directory": "packages/session-relay"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.mts",
|
|
21
|
+
"default": "./dist/index.mjs"
|
|
22
|
+
},
|
|
23
|
+
"./node": {
|
|
24
|
+
"types": "./dist/node.d.mts",
|
|
25
|
+
"default": "./dist/node.mjs"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@effect/platform-node": "4.0.0",
|
|
33
|
+
"effect": "4.0.0",
|
|
34
|
+
"vite-plus": "0.2.4"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@effect/platform-node": "^4.0.0",
|
|
38
|
+
"effect": "^4.0.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"@effect/platform-node": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "vp pack",
|
|
47
|
+
"typecheck": "tsgo --noEmit"
|
|
48
|
+
}
|
|
49
|
+
}
|