wirejs-deploy-amplify-basic 0.0.91 → 0.0.93-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.
@@ -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
+ }
@@ -3,6 +3,6 @@
3
3
  "dependencies": {
4
4
  "jsdom": "^25.0.1",
5
5
  "wirejs-dom": "^1.0.41",
6
- "wirejs-resources": "^0.1.59"
6
+ "wirejs-resources": "^0.1.61-realtime"
7
7
  }
8
8
  }
@@ -1 +1 @@
1
- export * from 'wirejs-resources/client';
1
+ export declare function apiTree(INTERNAL_API_URL: string, path?: string[]): () => void;
@@ -1 +1,79 @@
1
- export * from 'wirejs-resources/client';
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,127 @@
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
+ const ws = connections.get(url);
86
+ const subscribe = () => {
87
+ ws.send(JSON.stringify({
88
+ id: subscriptionId,
89
+ type: 'subscribe',
90
+ channel,
91
+ authorization
92
+ }));
93
+ channelSubs.set(fullChannelName, subscriptionId);
94
+ subscribers.set(subscriptionId, []);
95
+ };
96
+ if (ws.readyState === WebSocket.OPEN) {
97
+ subscribe();
98
+ }
99
+ else {
100
+ ws.addEventListener('open', subscribe);
101
+ }
102
+ }
103
+ subscribers.get(fullChannelName).push(subscriber);
104
+ }
105
+ export function unsubscribe(url, channel, subscriber) {
106
+ const fullChannelName = `${url}#${channel}`;
107
+ if (subscribers.has(fullChannelName)) {
108
+ const subs = subscribers.get(fullChannelName);
109
+ const index = subs.indexOf(subscriber);
110
+ if (index !== -1) {
111
+ subs.splice(index, 1);
112
+ if (subs.length === 0) {
113
+ channelSubs.delete(fullChannelName);
114
+ const urlSubs = Array.from(channelSubs.keys())
115
+ .filter(id => id.startsWith(`${url}#`));
116
+ if (urlSubs.length === 0) {
117
+ const ws = connections.get(url);
118
+ if (ws) {
119
+ ws.close();
120
+ connections.delete(url);
121
+ }
122
+ }
123
+ subscribers.delete(url);
124
+ }
125
+ }
126
+ }
127
+ }
package/dist/index.d.ts CHANGED
@@ -2,3 +2,4 @@ export * from 'wirejs-resources';
2
2
  export { FileService } from './services/file.js';
3
3
  export { AuthenticationService } from './services/authentication.js';
4
4
  export { DistributedTable } from './resources/distributed-table.js';
5
+ export { RealtimeService } from './services/realtime.js';
package/dist/index.js CHANGED
@@ -8,7 +8,10 @@ import { AuthenticationService } from './services/authentication.js';
8
8
  export { AuthenticationService } from './services/authentication.js';
9
9
  import { DistributedTable } from './resources/distributed-table.js';
10
10
  export { DistributedTable } from './resources/distributed-table.js';
11
+ import { RealtimeService } from './services/realtime.js';
12
+ export { RealtimeService } from './services/realtime.js';
11
13
  // expose resources to other resources that might depend on it.
12
14
  overrides.AuthenticationService = AuthenticationService;
13
15
  overrides.DistributedTable = DistributedTable;
14
16
  overrides.FileService = FileService;
17
+ overrides.RealtimeService = RealtimeService;
@@ -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 { SignatureV4 } from '@aws-sdk/signature-v4';
4
+ import { HttpRequest } from '@aws-sdk/protocol-http';
5
+ import { defaultProvider } from '@aws-sdk/credential-provider-node';
6
+ import { Sha256 } from '@aws-crypto/sha256-js';
7
+ import { addResource } from '../resource-collector.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.91",
3
+ "version": "0.0.93-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.59"
44
+ "wirejs-resources": "^0.1.61-realtime"
41
45
  },
42
46
  "devDependencies": {
43
47
  "@aws-amplify/backend": "^1.14.0",