wirejs-deploy-amplify-basic 0.0.91 → 0.0.92-realtime
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/amplify-backend-assets/backend.ts +27 -3
- package/amplify-backend-assets/constructs/realtime-service/authorizer-lambda.ts +76 -0
- package/amplify-backend-assets/constructs/realtime-service/index.ts +80 -0
- package/amplify-hosting-assets/compute/default/package.json +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +79 -1
- package/dist/client/realtime.d.ts +11 -0
- package/dist/client/realtime.js +118 -0
- package/dist/services/realtime.d.ts +12 -0
- package/dist/services/realtime.js +106 -0
- package/package.json +6 -2
|
@@ -5,11 +5,12 @@ import { RemovalPolicy } from "aws-cdk-lib";
|
|
|
5
5
|
import { FunctionUrlAuthType } from 'aws-cdk-lib/aws-lambda';
|
|
6
6
|
import { Bucket, BlockPublicAccess } from 'aws-cdk-lib/aws-s3';
|
|
7
7
|
import { Table, AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
|
|
8
|
+
import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
|
|
9
|
+
import { TableDefinition, Resource, indexName } from 'wirejs-resources';
|
|
10
|
+
import { TableIndexes } from './constructs/table-indexes';
|
|
11
|
+
import { RealtimeService } from './constructs/realtime-service';
|
|
8
12
|
import { api } from './functions/api/resource';
|
|
9
13
|
import { auth } from './auth/resource';
|
|
10
|
-
import { TableDefinition, indexName } from 'wirejs-resources';
|
|
11
|
-
import { TableIndexes } from './constructs/table-indexes';
|
|
12
|
-
import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
|
|
13
14
|
|
|
14
15
|
// @ts-ignore
|
|
15
16
|
import generatedResources from './generated-resources';
|
|
@@ -128,6 +129,29 @@ for (const resource of generated) {
|
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
function isRealtimeService(resource: any): resource is {
|
|
133
|
+
type: 'RealtimeService';
|
|
134
|
+
options: Resource;
|
|
135
|
+
} {
|
|
136
|
+
return resource.type === 'RealtimeService';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (generated.some(isRealtimeService)) {
|
|
140
|
+
const realtime = new RealtimeService(backend.stack, 'realtime', {
|
|
141
|
+
appId: APP_ID!,
|
|
142
|
+
branchId: BRANCH_ID,
|
|
143
|
+
publisher: backend.api,
|
|
144
|
+
namespaces: generated
|
|
145
|
+
.filter(isRealtimeService)
|
|
146
|
+
.map(r => r.options.absoluteId),
|
|
147
|
+
});
|
|
148
|
+
// TODO: is there a better way to ensure we grant access specifically
|
|
149
|
+
// to what `Secret` uses to store its creds without creating N places to
|
|
150
|
+
// map this?
|
|
151
|
+
// Longer term: Secrets will be stored either in DDB, parameter store, something
|
|
152
|
+
// else that is more appropriate than S3.
|
|
153
|
+
bucket.grantRead(realtime.authHandler);
|
|
154
|
+
}
|
|
131
155
|
|
|
132
156
|
/**
|
|
133
157
|
* Lambda environment vars
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as jose from 'jose';
|
|
2
|
+
import { Secret } from 'wirejs-resources';
|
|
3
|
+
|
|
4
|
+
type Operation = "EVENT_CONNECT" | "EVENT_SUBSCRIBE" | "EVENT_PUBLISH";
|
|
5
|
+
|
|
6
|
+
type AuthorizationRequest = {
|
|
7
|
+
authorizationToken: string,
|
|
8
|
+
requestContext: {
|
|
9
|
+
apiId: string,
|
|
10
|
+
accountId: string,
|
|
11
|
+
requestId: string,
|
|
12
|
+
operation: Operation,
|
|
13
|
+
channelNamespaceName: string,
|
|
14
|
+
channel: string
|
|
15
|
+
},
|
|
16
|
+
requestHeaders: Record<string, string>
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type AuthorizationResult = {
|
|
20
|
+
isAuthorized: boolean;
|
|
21
|
+
handlerContext?: Record<string, string>; // unused by wirejs
|
|
22
|
+
ttlOverride?: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
let secrets: Record<string, Promise<string> | undefined> = {};
|
|
26
|
+
async function getSecret(ns: string): Promise<any> {
|
|
27
|
+
if (!secrets[ns]) {
|
|
28
|
+
secrets[ns] = new Promise(async (resolve, reject) => {
|
|
29
|
+
try {
|
|
30
|
+
const secret = new Secret(ns, process.env.SECRET_ID!)
|
|
31
|
+
resolve(await secret.read());
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error('Error reading secret:', error);
|
|
34
|
+
reject(error);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return secrets[ns];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const handler = async (event: AuthorizationRequest): Promise<AuthorizationResult> => {
|
|
42
|
+
/**
|
|
43
|
+
* Intended authorization rules:
|
|
44
|
+
*
|
|
45
|
+
* 1. Must be connect or subscribe operation
|
|
46
|
+
* 2. Must be a valid JWT token
|
|
47
|
+
* 3. Must be signed with the correct secret
|
|
48
|
+
* 4. Must be for the correct channel
|
|
49
|
+
*/
|
|
50
|
+
try {
|
|
51
|
+
const token = event.authorizationToken;
|
|
52
|
+
if (!token) {
|
|
53
|
+
throw new Error('Authorization token is missing');
|
|
54
|
+
}
|
|
55
|
+
const decoded = await jose.jwtVerify(
|
|
56
|
+
token,
|
|
57
|
+
await getSecret(event.requestContext.channelNamespaceName)
|
|
58
|
+
);
|
|
59
|
+
if (decoded.payload.channel !== event.requestContext.channel) {
|
|
60
|
+
throw new Error('Channel mismatch');
|
|
61
|
+
}
|
|
62
|
+
if (!['EVENT_CONNECT', 'EVENT_SUBSCRIBE'].includes(event.requestContext.operation)) {
|
|
63
|
+
throw new Error('Operation mismatch');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Default TTL from AppSync is 5 minutes according to construct docstring.
|
|
67
|
+
return {
|
|
68
|
+
isAuthorized: true,
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error('Authorization error:', error);
|
|
72
|
+
return {
|
|
73
|
+
isAuthorized: false,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Duration } from 'aws-cdk-lib';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { IFunction, Runtime } from 'aws-cdk-lib/aws-lambda';
|
|
4
|
+
import { Construct } from 'constructs';
|
|
5
|
+
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
|
|
6
|
+
import {
|
|
7
|
+
AppSyncAuthProvider,
|
|
8
|
+
AppSyncAuthorizationType,
|
|
9
|
+
EventApi
|
|
10
|
+
} from 'aws-cdk-lib/aws-appsync';
|
|
11
|
+
|
|
12
|
+
const __filename = import.meta.url.replace(/^file:/, '');
|
|
13
|
+
const __dirname = path.dirname(__filename);
|
|
14
|
+
|
|
15
|
+
export class RealtimeService extends Construct {
|
|
16
|
+
authHandler: IFunction;
|
|
17
|
+
|
|
18
|
+
constructor(scope: Construct, id: string, props: {
|
|
19
|
+
appId: string;
|
|
20
|
+
branchId: string;
|
|
21
|
+
publisher: {
|
|
22
|
+
addEnvironment: (key: string, value: string) => void;
|
|
23
|
+
resources: {
|
|
24
|
+
lambda: IFunction;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
namespaces: string[];
|
|
28
|
+
}) {
|
|
29
|
+
super(scope, id);
|
|
30
|
+
let realtimeService: EventApi | undefined;
|
|
31
|
+
|
|
32
|
+
const LAMBDA = AppSyncAuthorizationType.LAMBDA;
|
|
33
|
+
const IAM = AppSyncAuthorizationType.IAM;
|
|
34
|
+
|
|
35
|
+
this.authHandler = new NodejsFunction(this, 'realtime-auth', {
|
|
36
|
+
runtime: Runtime.NODEJS_22_X,
|
|
37
|
+
handler: 'handler',
|
|
38
|
+
entry: path.join(__dirname, 'handler-lambda.ts'),
|
|
39
|
+
timeout: Duration.seconds(30),
|
|
40
|
+
environment: {
|
|
41
|
+
// must match wirejs-deploy-amplify-basic/wirejs-resources-overrides/services/realtime.ts:SECRET_ID
|
|
42
|
+
// TODO: find a common place to define this, or just pass it through
|
|
43
|
+
// in `addResource()`. (Redundant across `RealtimeService` instances.)
|
|
44
|
+
SECRET_ID: 'realtime-secret',
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const lambdaProvider: AppSyncAuthProvider = {
|
|
49
|
+
authorizationType: LAMBDA,
|
|
50
|
+
lambdaAuthorizerConfig: { handler: this.authHandler }
|
|
51
|
+
};
|
|
52
|
+
const iamProvider: AppSyncAuthProvider = { authorizationType: IAM };
|
|
53
|
+
|
|
54
|
+
realtimeService = new EventApi(this, 'realtime', {
|
|
55
|
+
apiName: `${props.appId}-${props.branchId}-realtime-api`,
|
|
56
|
+
authorizationConfig: {
|
|
57
|
+
authProviders: [iamProvider, lambdaProvider],
|
|
58
|
+
connectionAuthModeTypes: [LAMBDA],
|
|
59
|
+
defaultSubscribeAuthModeTypes: [LAMBDA],
|
|
60
|
+
defaultPublishAuthModeTypes: [IAM],
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// TODO: move this to backend.ts so all env vars are set in one place
|
|
65
|
+
props.publisher.addEnvironment(
|
|
66
|
+
'REALTIME_WS_DOMAIN', realtimeService.realtimeDns,
|
|
67
|
+
);
|
|
68
|
+
props.publisher.addEnvironment(
|
|
69
|
+
'REALTIME_HTTP_DOMAIN', realtimeService.httpDns,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
for (const namespaceBaseName of props.namespaces) {
|
|
73
|
+
const resourceName = namespaceBaseName
|
|
74
|
+
.replace(/[^a-zA-Z0-9-_]/g, '_')
|
|
75
|
+
.slice(0, 50);
|
|
76
|
+
const ns = realtimeService.addChannelNamespace(resourceName);
|
|
77
|
+
ns.grantPublish(props.publisher.resources.lambda);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
export declare function apiTree(INTERNAL_API_URL: string, path?: string[]): () => void;
|
package/dist/client/index.js
CHANGED
|
@@ -1 +1,79 @@
|
|
|
1
|
-
|
|
1
|
+
import * as rt from './realtime.js';
|
|
2
|
+
async function callApi(INTERNAL_API_URL, method, ...args) {
|
|
3
|
+
function isNode() {
|
|
4
|
+
return typeof args[0]?.cookies?.getAll === 'function';
|
|
5
|
+
}
|
|
6
|
+
function apiUrl() {
|
|
7
|
+
if (isNode()) {
|
|
8
|
+
return INTERNAL_API_URL;
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
return "/api";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
let cookieHeader = {};
|
|
15
|
+
if (isNode()) {
|
|
16
|
+
const context = args[0];
|
|
17
|
+
const cookies = context.cookies.getAll();
|
|
18
|
+
cookieHeader = typeof cookies === 'object'
|
|
19
|
+
? {
|
|
20
|
+
Cookie: Object.entries(cookies).map(kv => kv.join('=')).join('; ')
|
|
21
|
+
}
|
|
22
|
+
: {};
|
|
23
|
+
}
|
|
24
|
+
const response = await fetch(apiUrl(), {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: {
|
|
27
|
+
'Content-Type': 'application/json',
|
|
28
|
+
...cookieHeader
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify([{ method, args: [...args] }]),
|
|
31
|
+
});
|
|
32
|
+
const body = await response.json();
|
|
33
|
+
if (isNode()) {
|
|
34
|
+
const context = args[0];
|
|
35
|
+
for (const c of response.headers.getSetCookie()) {
|
|
36
|
+
const parts = c.split(';').map(p => p.trim());
|
|
37
|
+
const flags = parts.slice(1);
|
|
38
|
+
const [name, value] = parts[0].split('=').map(decodeURIComponent);
|
|
39
|
+
const httpOnly = flags.includes('HttpOnly');
|
|
40
|
+
const secure = flags.includes('Secure');
|
|
41
|
+
const maxAgePart = flags.find(f => f.startsWith('Max-Age='))?.split('=')[1];
|
|
42
|
+
context.cookies.set({
|
|
43
|
+
name,
|
|
44
|
+
value,
|
|
45
|
+
httpOnly,
|
|
46
|
+
secure,
|
|
47
|
+
maxAge: maxAgePart ? parseInt(maxAgePart) : undefined
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const error = body[0].error;
|
|
52
|
+
if (error) {
|
|
53
|
+
throw new Error(error);
|
|
54
|
+
}
|
|
55
|
+
const value = body[0].data;
|
|
56
|
+
if (typeof value === 'object' && value.__wjstype === 'realtime') {
|
|
57
|
+
return {
|
|
58
|
+
subscribe(subscriber) {
|
|
59
|
+
rt.subscribe(value.url, value.channel, value.token, subscriber);
|
|
60
|
+
return () => {
|
|
61
|
+
rt.unsubscribe(value.url, value.channel, subscriber);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
;
|
|
69
|
+
export function apiTree(INTERNAL_API_URL, path = []) {
|
|
70
|
+
return new Proxy(function () { }, {
|
|
71
|
+
apply(_target, _thisArg, args) {
|
|
72
|
+
return callApi(INTERNAL_API_URL, path, ...args);
|
|
73
|
+
},
|
|
74
|
+
get(_target, prop) {
|
|
75
|
+
return apiTree(INTERNAL_API_URL, [...path, prop]);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type Subscriber = {
|
|
2
|
+
onmessage: (data: any) => void;
|
|
3
|
+
onclose?: () => void;
|
|
4
|
+
};
|
|
5
|
+
export type ChannelEvent<T = any> = {
|
|
6
|
+
type: "data" | "broadcast_error" | "ka";
|
|
7
|
+
id: string;
|
|
8
|
+
event: any;
|
|
9
|
+
};
|
|
10
|
+
export declare function subscribe(url: string, channel: string, token: string, subscriber: Subscriber): void;
|
|
11
|
+
export declare function unsubscribe(url: string, channel: string, subscriber: Subscriber): void;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// AppSync RTS has a few complexities we don't handle in local mode.
|
|
2
|
+
// 1. We connect *one time* per URL.
|
|
3
|
+
// 2. We subscribe to a channel within the WebSocket connection *one time*.
|
|
4
|
+
// 3. Each new subscriber need to listen to the "connection"
|
|
5
|
+
/**
|
|
6
|
+
* URL -> WebSocket connection
|
|
7
|
+
*/
|
|
8
|
+
const connections = new Map();
|
|
9
|
+
/**
|
|
10
|
+
* `${URL}#${channel}` -> subscription ID
|
|
11
|
+
*/
|
|
12
|
+
const channelSubs = new Map();
|
|
13
|
+
/**
|
|
14
|
+
* subscription ID -> subscriber
|
|
15
|
+
*/
|
|
16
|
+
const subscribers = new Map();
|
|
17
|
+
/**
|
|
18
|
+
* Encodes an object into Base64 URL format.
|
|
19
|
+
* From https://docs.aws.amazon.com/appsync/latest/eventapi/event-api-websocket-protocol.html#websocket-connection-handshake
|
|
20
|
+
* @param {*} authorization - an object with the required authorization properties
|
|
21
|
+
**/
|
|
22
|
+
function getBase64URLEncoded(authorization) {
|
|
23
|
+
return btoa(JSON.stringify(authorization))
|
|
24
|
+
.replace(/\+/g, '-') // Convert '+' to '-'
|
|
25
|
+
.replace(/\//g, '_') // Convert '/' to '_'
|
|
26
|
+
.replace(/=+$/, ''); // Remove padding `=`
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* @param authorization
|
|
30
|
+
* @returns
|
|
31
|
+
*/
|
|
32
|
+
function getAuthProtocol(authorization) {
|
|
33
|
+
const header = getBase64URLEncoded(authorization);
|
|
34
|
+
return `header-${header}`;
|
|
35
|
+
}
|
|
36
|
+
export function subscribe(url, channel, token, subscriber) {
|
|
37
|
+
const fullChannelName = `${url}#${channel}`;
|
|
38
|
+
const authorization = {
|
|
39
|
+
Authorization: token,
|
|
40
|
+
host: new URL(url).host,
|
|
41
|
+
};
|
|
42
|
+
if (!connections.has(url)) {
|
|
43
|
+
const ws = new WebSocket(url, [
|
|
44
|
+
'aws-appsync-event-ws',
|
|
45
|
+
getAuthProtocol(authorization)
|
|
46
|
+
]);
|
|
47
|
+
connections.set(url, ws);
|
|
48
|
+
ws.onmessage = event => {
|
|
49
|
+
const data = JSON.parse(event.data);
|
|
50
|
+
if (data.type === 'data') {
|
|
51
|
+
for (const subscriber of subscribers.get(data.id) || []) {
|
|
52
|
+
try {
|
|
53
|
+
subscriber.onmessage(data.event);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
console.error('Error in subscriber onmessage:', error);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
ws.onclose = event => {
|
|
62
|
+
const subscriptionIds = Array.from(channelSubs.keys())
|
|
63
|
+
.filter(id => id.startsWith(`${url}#`));
|
|
64
|
+
for (const subscriptionId of subscriptionIds) {
|
|
65
|
+
const subs = subscribers.get(subscriptionId);
|
|
66
|
+
if (subs) {
|
|
67
|
+
for (const subscriber of subs) {
|
|
68
|
+
if (subscriber.onclose) {
|
|
69
|
+
try {
|
|
70
|
+
subscriber.onclose();
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
console.error('Error in subscriber onclose:', error);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
subscribers.delete(subscriptionId);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
console.log('closed', event);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (!channelSubs.has(channel)) {
|
|
84
|
+
const subscriptionId = crypto.randomUUID();
|
|
85
|
+
connections.get(url).send(JSON.stringify({
|
|
86
|
+
id: subscriptionId,
|
|
87
|
+
type: 'subscribe',
|
|
88
|
+
channel,
|
|
89
|
+
authorization
|
|
90
|
+
}));
|
|
91
|
+
channelSubs.set(fullChannelName, subscriptionId);
|
|
92
|
+
subscribers.set(subscriptionId, []);
|
|
93
|
+
}
|
|
94
|
+
subscribers.get(fullChannelName).push(subscriber);
|
|
95
|
+
}
|
|
96
|
+
export function unsubscribe(url, channel, subscriber) {
|
|
97
|
+
const fullChannelName = `${url}#${channel}`;
|
|
98
|
+
if (subscribers.has(fullChannelName)) {
|
|
99
|
+
const subs = subscribers.get(fullChannelName);
|
|
100
|
+
const index = subs.indexOf(subscriber);
|
|
101
|
+
if (index !== -1) {
|
|
102
|
+
subs.splice(index, 1);
|
|
103
|
+
if (subs.length === 0) {
|
|
104
|
+
channelSubs.delete(fullChannelName);
|
|
105
|
+
const urlSubs = Array.from(channelSubs.keys())
|
|
106
|
+
.filter(id => id.startsWith(`${url}#`));
|
|
107
|
+
if (urlSubs.length === 0) {
|
|
108
|
+
const ws = connections.get(url);
|
|
109
|
+
if (ws) {
|
|
110
|
+
ws.close();
|
|
111
|
+
connections.delete(url);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
subscribers.delete(url);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Resource } from 'wirejs-resources';
|
|
2
|
+
export declare const SECRET_ID = "realtime-secret";
|
|
3
|
+
export declare class RealtimeService<T = any> extends Resource {
|
|
4
|
+
#private;
|
|
5
|
+
constructor(scope: Resource | string, id: string);
|
|
6
|
+
/**
|
|
7
|
+
* The address the client will need to connect to.
|
|
8
|
+
*/
|
|
9
|
+
get address(): string;
|
|
10
|
+
publish(channel: string, events: T[]): Promise<void>;
|
|
11
|
+
getStream(channel: string): Promise<any>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import * as jose from 'jose';
|
|
2
|
+
import { Resource, Secret } from 'wirejs-resources';
|
|
3
|
+
import { addResource } from '../resource-collector.js';
|
|
4
|
+
import { SignatureV4 } from '@aws-sdk/signature-v4';
|
|
5
|
+
import { HttpRequest } from '@aws-sdk/protocol-http';
|
|
6
|
+
import { defaultProvider } from '@aws-sdk/credential-provider-node';
|
|
7
|
+
import { Sha256 } from '@aws-crypto/sha256-js';
|
|
8
|
+
export const SECRET_ID = 'realtime-secret';
|
|
9
|
+
export class RealtimeService extends Resource {
|
|
10
|
+
#secret;
|
|
11
|
+
constructor(scope, id) {
|
|
12
|
+
super(scope, id);
|
|
13
|
+
this.#secret = new Secret(this, SECRET_ID);
|
|
14
|
+
addResource('RealtimeService', { absoluteId: this.absoluteId });
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Ensures channel name will be supported by known, major cloud provers using
|
|
18
|
+
* "serverless" solutions.
|
|
19
|
+
*
|
|
20
|
+
* Least common denominator on channel naming appears to be AWS AppSync:
|
|
21
|
+
* Ref: https://docs.aws.amazon.com/appsync/latest/eventapi/event-api-websocket-protocol.html#realtime-websocket-operations
|
|
22
|
+
*
|
|
23
|
+
* AFAIK, other providers allow 255 and beyond with a similar limitations. GCP PubSub
|
|
24
|
+
* doesn't support slashes in the topic name. But, on GCP, we can probably replaces
|
|
25
|
+
* slashes with underscores, and actual underscores with double-underscores...
|
|
26
|
+
*
|
|
27
|
+
* @param name
|
|
28
|
+
*/
|
|
29
|
+
#validateChannelName(name) {
|
|
30
|
+
if (!name.match(/^\/?[A-Za-z0-9](?:[A-Za-z0-9-]{0,48}[A-Za-z0-9])?(?:\/[A-Za-z0-9](?:[A-Za-z0-9-]{0,48}[A-Za-z0-9])?){0,4}\/?$/)) {
|
|
31
|
+
throw new Error("Channel name must be no more than 4 segments of 1-50 alphanumeric characters and dashes each.");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The address the client will need to connect to.
|
|
36
|
+
*/
|
|
37
|
+
get address() {
|
|
38
|
+
return `ws://${process.env.REALTIME_WS_DOMAIN}/event`;
|
|
39
|
+
}
|
|
40
|
+
async publish(channel, events) {
|
|
41
|
+
this.#validateChannelName(channel);
|
|
42
|
+
console.log('Publishing to channels:', channel);
|
|
43
|
+
// AppSync allows batches of no more than 5. Hence, if we have more than 5,
|
|
44
|
+
// we need to perform batching on our end.
|
|
45
|
+
if (events.length > 5) {
|
|
46
|
+
let i = 0;
|
|
47
|
+
while (i < events.length) {
|
|
48
|
+
const batch = events.slice(i, i + 5);
|
|
49
|
+
await this.publish(channel, batch);
|
|
50
|
+
i += 5;
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// TODO: Utility for making SigV4 requests a little more concise.
|
|
55
|
+
const credentials = await defaultProvider()();
|
|
56
|
+
const signer = new SignatureV4({
|
|
57
|
+
service: 'appsync',
|
|
58
|
+
region: process.env.AWS_REGION || 'us-east-1',
|
|
59
|
+
credentials,
|
|
60
|
+
sha256: Sha256,
|
|
61
|
+
});
|
|
62
|
+
const request = new HttpRequest({
|
|
63
|
+
method: 'POST',
|
|
64
|
+
protocol: 'https:',
|
|
65
|
+
// hostname: `${process.env.REALTIME_DOMAIN}.appsync-api.${process.env.AWS_REGION}.amazonaws.com`,
|
|
66
|
+
hostname: process.env.REALTIME_HTTP_DOMAIN,
|
|
67
|
+
path: '/event',
|
|
68
|
+
headers: {
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
'x-amz-date': new Date().toISOString(),
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
channel,
|
|
74
|
+
events: {
|
|
75
|
+
channel: `default/${channel}`,
|
|
76
|
+
events: events.map(event => JSON.stringify(event))
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
const signedRequest = await signer.sign(request);
|
|
81
|
+
const response = await fetch(`https://${signedRequest.hostname}${signedRequest.path}`, {
|
|
82
|
+
method: signedRequest.method,
|
|
83
|
+
headers: signedRequest.headers,
|
|
84
|
+
body: signedRequest.body,
|
|
85
|
+
});
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
throw new Error(`Failed to publish to channel: ${response.statusText}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async getStream(channel) {
|
|
91
|
+
this.#validateChannelName(channel);
|
|
92
|
+
const channelString = Buffer.from(channel).toString('base64');
|
|
93
|
+
const payload = { channel };
|
|
94
|
+
const jwt = await new jose.SignJWT(payload)
|
|
95
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
96
|
+
.setIssuedAt()
|
|
97
|
+
.setExpirationTime(`10s`)
|
|
98
|
+
.sign(new TextEncoder().encode(await this.#secret.read()));
|
|
99
|
+
return {
|
|
100
|
+
__wjstype: 'realtime',
|
|
101
|
+
url: `${this.address}/${channelString}`,
|
|
102
|
+
channel,
|
|
103
|
+
token: jwt
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wirejs-deploy-amplify-basic",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.92-realtime",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -27,17 +27,21 @@
|
|
|
27
27
|
"wirejs-deploy-amplify-basic": "build.js"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
+
"@aws-crypto/sha256-js": "^5.2.0",
|
|
30
31
|
"@aws-sdk/client-cognito-identity-provider": "^3.741.0",
|
|
31
32
|
"@aws-sdk/client-dynamodb": "^3.774.0",
|
|
32
33
|
"@aws-sdk/client-s3": "^3.738.0",
|
|
34
|
+
"@aws-sdk/credential-provider-node": "^3.806.0",
|
|
33
35
|
"@aws-sdk/lib-dynamodb": "^3.778.0",
|
|
36
|
+
"@aws-sdk/protocol-http": "^3.370.0",
|
|
37
|
+
"@aws-sdk/signature-v4": "^3.370.0",
|
|
34
38
|
"copy": "^0.3.2",
|
|
35
39
|
"esbuild": "^0.24.2",
|
|
36
40
|
"jsdom": "^25.0.0",
|
|
37
41
|
"recursive-copy": "^2.0.14",
|
|
38
42
|
"rimraf": "^6.0.1",
|
|
39
43
|
"wirejs-dom": "^1.0.41",
|
|
40
|
-
"wirejs-resources": "^0.1.
|
|
44
|
+
"wirejs-resources": "^0.1.60-realtime"
|
|
41
45
|
},
|
|
42
46
|
"devDependencies": {
|
|
43
47
|
"@aws-amplify/backend": "^1.14.0",
|