Skip to content
Open

SSR #1905

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion client/packages/admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
type InstantIssue,
type TransactionChunk,
type AuthToken,
type Exactly,

// core types
type User,
Expand Down Expand Up @@ -69,6 +68,7 @@ import {
type DeleteFileResponse,
validateQuery,
validateTransactions,
createInstantRouteHandler,
} from '@instantdb/core';

import version from './version.ts';
Expand Down Expand Up @@ -1075,6 +1075,7 @@ export {
tx,
lookup,
i,
createInstantRouteHandler,

// error
InstantAPIError,
Expand Down
123 changes: 123 additions & 0 deletions client/packages/core/__tests__/src/serializeSchema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { expect, test } from 'vitest';
import { i } from '../../src/schema';
import { parseSchemaFromJSON } from '../../src/parseSchemaFromJSON';
import { InstantSchemaDef } from '../../src/schemaTypes';

const schema = i.schema({
entities: {
users: i.entity({
name: i.string(),
email: i.string().indexed().unique(),
bio: i.string().optional(),
// this is a convenient way to typecheck custom JSON fields
// though we should probably have a backend solution for this
stuff: i.json<{ custom: string }>(),
junk: i.any(),
}),
posts: i.entity({
title: i.string().optional(),
body: i.string(),
}),
comments: i.entity({
body: i.string().indexed(),
likes: i.number(),
}),

birthdays: i.entity({
date: i.date(),
message: i.string(),
prizes: i.json<string | number>(),
}),
},
links: {
usersPosts: {
forward: {
on: 'users',
has: 'many',
label: 'posts',
},
reverse: {
on: 'posts',
has: 'one',
label: 'author',
},
},
postsComments: {
forward: {
on: 'posts',
has: 'many',
label: 'comments',
},
reverse: {
on: 'comments',
has: 'one',
label: 'post',
},
},
friendships: {
forward: {
on: 'users',
has: 'many',
label: 'friends',
},
reverse: {
on: 'users',
has: 'many',
label: '_friends',
},
},
referrals: {
forward: {
on: 'users',
has: 'many',
label: 'referred',
},
reverse: {
on: 'users',
has: 'one',
label: 'referrer',
},
},
},
rooms: {
chat: {
presence: i.entity({
name: i.string(),
status: i.string(),
}),
topics: {
sendEmoji: i.entity({
emoji: i.string(),
}),
},
},
},
});

type AnySchema = InstantSchemaDef<any, any, any>;

// compare schemas by stringifying them with json and comparing the strings
const compareSchemas = (schema1: AnySchema, schema2: AnySchema) => {
expect(JSON.stringify(schema1, null, 2)).toBe(
JSON.stringify(schema2, null, 2),
);
};

test('stuff', () => {
const stringified = JSON.stringify(schema, null, 2);
const parsed = JSON.parse(stringified);
console.log(stringified);

const otherSide = parseSchemaFromJSON(parsed);

compareSchemas(schema, otherSide);

expect(schema.entities.comments.links).toEqual(
otherSide.entities.comments.links,
);
expect(schema.entities.comments.asType).toEqual(
otherSide.entities.comments.asType,
);

expect(schema).toStrictEqual(otherSide);
});
76 changes: 67 additions & 9 deletions client/packages/core/src/Reactor.js
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,9 @@ export default class Reactor {
this._oauthCallbackResponse = this._oauthLoginInit();

// kick off a request to cache it
this.getCurrentUser();
this.getCurrentUser().then((userInfo) => {
this.syncUserToEndpoint(userInfo.user);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the cookie only lives for 24 hours, should we have something in reactor that periodically refreshes it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have it sync in the background when reactor gets init'd, not sure of a better solution? ideas welcome

});

NetworkListener.getIsOnline().then((isOnline) => {
this._isOnline = isOnline;
Expand Down Expand Up @@ -490,6 +492,43 @@ export default class Reactor {
}
}

/**
* Does the same thing as handle-query-add-ok
* but called as a result of receiving query info from ssr
* @param {any} q
* @param {{ triples: any; pageInfo: any; }} result
* @param {boolean} enableCardinalityInference
*/
_addQueryData(q, result, enableCardinalityInference) {
if (!this.attrs) {
throw new Error('Attrs in reactor have not been set');
}
const queryHash = weakHash(q);
const store = s.createStore(
this.attrs,
result.triples,
enableCardinalityInference,
this._linkIndex,
this.config.useDateObjects,
);
this.querySubs.set((prev) => {
prev[queryHash] = {
result: {
store,
pageInfo: result.pageInfo,
processedTxId: undefined,
isExternal: true,
},
q,
};
return prev;
});
this._cleanupPendingMutationsQueries();
this.notifyOne(queryHash);
this.notifyOneQueryOnce(queryHash);
this._cleanupPendingMutationsTimeout();
}

_handleReceive(connId, msg) {
// opt-out, enabled by default if schema
const enableCardinalityInference =
Expand Down Expand Up @@ -1065,7 +1104,7 @@ export default class Reactor {
}

/** Runs instaql on a query and a store */
dataForQuery(hash) {
dataForQuery(hash, applyOptimistic = true) {
const errorMessage = this._errorMessage;
if (errorMessage) {
return { error: errorMessage };
Expand All @@ -1089,17 +1128,16 @@ export default class Reactor {
return cached.data;
}

const { store, pageInfo, aggregate, processedTxId } = result;
let store = result.store;
const { pageInfo, aggregate, processedTxId } = result;
const mutations = this._rewriteMutationsSorted(
store.attrs,
pendingMutations,
);
const newStore = this._applyOptimisticUpdates(
store,
mutations,
processedTxId,
);
const resp = instaql({ store: newStore, pageInfo, aggregate }, q);
if (applyOptimistic) {
store = this._applyOptimisticUpdates(store, mutations, processedTxId);
}
const resp = instaql({ store: store, pageInfo, aggregate }, q);

this._dataForQueryCache[hash] = {
querySubVersion,
Expand Down Expand Up @@ -1817,7 +1855,27 @@ export default class Reactor {
}
}

async syncUserToEndpoint(user) {
if (this.config.endpointURI) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

endpointURI seems too generic

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i partially intentionally left it generic to account for more possible webhook actions in the future, also can't really think of a good name

try {
fetch(this.config.endpointURI + '/sync-auth', {
method: 'POST',
body: JSON.stringify({
user: user,
}),
headers: {
'Content-Type': 'application/json',
},
});
} catch (error) {
console.error('Error syncing user with external endpoint', error);
}
}
}

updateUser(newUser) {
this.syncUserToEndpoint(newUser);

const newV = { error: undefined, user: newUser };
this._currentUserCached = { isLoading: false, ...newV };
this._dataForQueryCache = {};
Expand Down
44 changes: 44 additions & 0 deletions client/packages/core/src/createRouteHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export const createInstantRouteHandler = (config: {
appId: string;
apiURI?: string;
}) => {
async function handleUserSync(req: Request) {
const body = await req.json();
if (body.user && body.user.refresh_token) {
return new Response('sync', {
headers: {
// 24 hour expiry
'Set-Cookie': `instant_refresh_token=${body.user.refresh_token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=86400`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if you're testing on localhost? I would expect the Secure field to prevent this from working.

},
});
} else {
return new Response('sync', {
headers: {
// remove the cookie (some browsers)
'Set-Cookie': `instant_refresh_token=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=-1`,
},
});
}
}

return {
GET: async (_req: Request) => {
return new Response('Method not allowed', {
status: 405,
statusText: 'Method Not Allowed',
});
},
POST: async (req: Request) => {
const url = new URL(req.url);
const pathname = url.pathname;
const route = pathname.split('/')[pathname.split('/').length - 1];
switch (route) {
case 'sync-auth':
return await handleUserSync(req);
}
return new Response('Route not found', {
status: 404,
});
},
};
};
Loading
Loading